# Copyright 2022 The Pigweed Authors # # Licensed 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 # # https://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. """Creates a new Pigweed module.""" from __future__ import annotations import abc import argparse import dataclasses from dataclasses import dataclass import datetime import difflib from enum import Enum import functools import json import logging from pathlib import Path import re import sys from typing import Any, Collection, Iterable, Type from prompt_toolkit import prompt from pw_build import generate_modules_lists import pw_cli.color import pw_cli.env from pw_cli.diff import colorize_diff from pw_cli.status_reporter import StatusReporter from pw_module.templates import get_template _COLOR = pw_cli.color.colors() _LOG = logging.getLogger(__name__) _PW_ENV = pw_cli.env.pigweed_environment() _PW_ROOT = _PW_ENV.PW_ROOT _PIGWEED_LICENSE = f""" # Copyright {datetime.datetime.now().year} The Pigweed Authors # # Licensed 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 # # https://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.""".lstrip() _PIGWEED_LICENSE_CC = _PIGWEED_LICENSE.replace('#', '//') _CREATE = _COLOR.green('create ') _REPLACE = _COLOR.green('replace ') _UPDATE = _COLOR.yellow('update ') _UNCHANGED = _COLOR.blue('unchanged ') _IDENTICAL = _COLOR.blue('identical ') _REPORT = StatusReporter() def _report_write_file(file_path: Path) -> None: """Print a notification when a file is newly created or replaced.""" relative_file_path = str(file_path.relative_to(_PW_ROOT)) if file_path.is_file(): _REPORT.info(_REPLACE + relative_file_path) return _REPORT.new(_CREATE + relative_file_path) def _report_unchanged_file(file_path: Path) -> None: """Print a notification a file was not updated/changed.""" relative_file_path = str(file_path.relative_to(_PW_ROOT)) _REPORT.ok(_UNCHANGED + relative_file_path) def _report_identical_file(file_path: Path) -> None: """Print a notification a file is identical.""" relative_file_path = str(file_path.relative_to(_PW_ROOT)) _REPORT.ok(_IDENTICAL + relative_file_path) def _report_edited_file(file_path: Path) -> None: """Print a notification a file was modified/edited.""" relative_file_path = str(file_path.relative_to(_PW_ROOT)) _REPORT.new(_UPDATE + relative_file_path) class PromptChoice(Enum): """Possible prompt responses.""" YES = 'yes' NO = 'no' DIFF = 'diff' def _prompt_user(message: str, allow_diff: bool = False) -> PromptChoice: """Prompt the user for to choose between yes, no and optionally diff. If the user presses enter with no text the response is assumed to be NO. If the user presses ctrl-c call sys.exit(1). Args: message: The message to display at the start of the prompt. allow_diff: If true add a 'd' to the help text in the prompt line. Returns: A PromptChoice enum value. """ help_text = '[y/N]' if allow_diff: help_text = '[y/N/d]' try: decision = prompt(f'{message} {help_text} ') except KeyboardInterrupt: sys.exit(1) # Ctrl-C pressed if not decision or decision.lower().startswith('n'): return PromptChoice.NO if decision.lower().startswith('y'): return PromptChoice.YES if decision.lower().startswith('d'): return PromptChoice.DIFF return PromptChoice.NO def _print_diff(file_name: Path | str, in_text: str, out_text: str) -> None: result_diff = list( difflib.unified_diff( in_text.splitlines(True), out_text.splitlines(True), f'{file_name} (original)', f'{file_name} (updated)', ) ) if not result_diff: return print() print(''.join(colorize_diff(result_diff))) def _prompt_overwrite(file_path: Path, new_contents: str) -> bool: """Returns true if a file should be written, prompts the user if needed.""" # File does not exist if not file_path.is_file(): return True # File exists but is identical. old_contents = file_path.read_text(encoding='utf-8') if new_contents and old_contents == new_contents: _report_identical_file(file_path) return False file_name = file_path.relative_to(_PW_ROOT) # File exists and is different. _REPORT.wrn(f'{file_name} already exists.') while True: choice = _prompt_user('Overwrite?', allow_diff=True) if choice == PromptChoice.DIFF: _print_diff(file_name, old_contents, new_contents) else: if choice == PromptChoice.YES: return True break # By default do not overwrite. _report_unchanged_file(file_path) return False # TODO(frolv): Adapted from pw_protobuf. Consolidate them. class _OutputFile: DEFAULT_INDENT_WIDTH = 2 def __init__(self, file: Path, indent_width: int = DEFAULT_INDENT_WIDTH): self._file = file self._content: list[str] = [] self._indent_width: int = indent_width self._indentation = 0 def line(self, line: str = '') -> None: if line: self._content.append(' ' * self._indentation) self._content.append(line) self._content.append('\n') def indent( self, width: int | None = None, ) -> _OutputFile._IndentationContext: """Increases the indentation level of the output.""" return self._IndentationContext( self, width if width is not None else self._indent_width ) @property def path(self) -> Path: return self._file @property def content(self) -> str: return ''.join(self._content) def write(self, content: str | None = None) -> None: """Write file contents. Prompts the user if necessary. Args: content: If provided will write this text to the file instead of calling self.content. """ output_text = self.content if content: output_text = content if not output_text.endswith('\n'): output_text += '\n' if _prompt_overwrite(self._file, new_contents=output_text): _report_write_file(self._file) self._file.write_text(output_text) def write_template(self, template_name: str, **template_args) -> None: template = get_template(template_name) rendered_template = template.render(**template_args) self.write(content=rendered_template) class _IndentationContext: """Context that increases the output's indentation when it is active.""" def __init__(self, output: _OutputFile, width: int): self._output = output self._width: int = width def __enter__(self): self._output._indentation += self._width def __exit__(self, typ, value, traceback): self._output._indentation -= self._width class _ModuleName: _MODULE_NAME_REGEX = re.compile( # Match the two letter character module prefix e.g. 'pw': r'^(?P[a-zA-Z]{2,})' # The rest of the module name consisting of multiple groups of a single # underscore followed by alphanumeric characters. This prevents multiple # underscores from appearing in a row and the name from ending in a an # underscore. r'(?P
' r'(_[a-zA-Z0-9]+)+' r')$' ) def __init__(self, prefix: str, main: str, path: Path) -> None: self._prefix = prefix self._main = main.lstrip('_') # Remove the leading underscore self._path = path @property def path(self) -> str: # Check if there are no parent directories for the full path. # Note: This relies on Path('pw_module').parents returning Path('.') for # paths that have no parent directories: # https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parents if self._path == Path('.'): return self.full return (self._path / self.full).as_posix() @property def full(self) -> str: return f'{self._prefix}_{self._main}' @property def prefix(self) -> str: return self._prefix @property def main(self) -> str: return self._main @property def default_namespace(self) -> str: return f'{self._prefix}::{self._main}' def upper_camel_case(self) -> str: return ''.join(s.capitalize() for s in self._main.split('_')) @property def header_line(self) -> str: return '=' * len(self.full) def __str__(self) -> str: return self.full def __repr__(self) -> str: return self.full @classmethod def parse(cls, name: str) -> _ModuleName | None: module_path = Path(name) module_name = module_path.name match = _ModuleName._MODULE_NAME_REGEX.fullmatch(module_name) if not match: return None parts = match.groupdict() return cls(parts['prefix'], parts['main'], module_path.parents[0]) @dataclass class _ModuleContext: name: _ModuleName dir: Path root_build_files: list[_BuildFile] sub_build_files: list[_BuildFile] build_systems: list[str] is_upstream: bool def build_files(self) -> Iterable[_BuildFile]: yield from self.root_build_files yield from self.sub_build_files def add_docs_file(self, file: Path): for build_file in self.root_build_files: build_file.add_docs_source(str(file.relative_to(self.dir))) def add_cc_target(self, target: _BuildFile.CcTarget) -> None: for build_file in self.root_build_files: build_file.add_cc_target(target) def add_cc_test(self, target: _BuildFile.CcTarget) -> None: for build_file in self.root_build_files: build_file.add_cc_test(target) class _BuildFile: """Abstract representation of a build file for a module.""" @dataclass class Target: name: str # TODO(frolv): Shouldn't be a string list as that's build system # specific. Figure out a way to resolve dependencies from targets. deps: list[str] = dataclasses.field(default_factory=list) @dataclass class CcTarget(Target): sources: list[Path] = dataclasses.field(default_factory=list) headers: list[Path] = dataclasses.field(default_factory=list) def rebased_sources(self, rebase_path: Path) -> Iterable[str]: return (str(src.relative_to(rebase_path)) for src in self.sources) def rebased_headers(self, rebase_path: Path) -> Iterable[str]: return (str(hdr.relative_to(rebase_path)) for hdr in self.headers) def __init__(self, path: Path, ctx: _ModuleContext): self._path = path self._ctx = ctx self._docs_sources: list[str] = [] self._cc_targets: list[_BuildFile.CcTarget] = [] self._cc_tests: list[_BuildFile.CcTarget] = [] @property def path(self) -> Path: return self._path @property def dir(self) -> Path: return self._path.parent def add_docs_source(self, filename: str) -> None: self._docs_sources.append(filename) def add_cc_target(self, target: CcTarget) -> None: self._cc_targets.append(target) def add_cc_test(self, target: CcTarget) -> None: self._cc_tests.append(target) @property def get_license(self) -> str: if self._ctx.is_upstream: return _PIGWEED_LICENSE return '' @property def docs_sources(self) -> list[str]: return self._docs_sources @property def cc_targets(self) -> list[_BuildFile.CcTarget]: return self._cc_targets @property def cc_tests(self) -> list[_BuildFile.CcTarget]: return self._cc_tests def relative_file(self, file_path: Path | str) -> str: if isinstance(file_path, str): return file_path return str(file_path.relative_to(self._path.parent)) def write(self) -> None: """Writes the contents of the build file to disk.""" file = _OutputFile(self._path, self._indent_width()) if self._ctx.is_upstream: file.line(_PIGWEED_LICENSE) file.line() self._write_preamble(file) for target in self._cc_targets: file.line() self._write_cc_target(file, target) for target in self._cc_tests: file.line() self._write_cc_test(file, target) if self._docs_sources: file.line() self._write_docs_target(file, self._docs_sources) file.write() @abc.abstractmethod def _indent_width(self) -> int: """Returns the default indent width for the build file's code style.""" @abc.abstractmethod def _write_preamble(self, file: _OutputFile) -> None: """Formats""" @abc.abstractmethod def _write_cc_target( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: """Defines a C++ library target within the build file.""" @abc.abstractmethod def _write_cc_test( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: """Defines a C++ unit test target within the build file.""" @abc.abstractmethod def _write_docs_target( self, file: _OutputFile, docs_sources: list[str], ) -> None: """Defines a documentation target within the build file.""" # TODO(frolv): The dict here should be dict[str, '_GnVal'] (i.e. _GnScope), # but mypy does not yet support recursive types: # https://github.com/python/mypy/issues/731 _GnVal = bool | int | str | list[str] | dict[str, Any] _GnScope = dict[str, _GnVal] class _GnBuildFile(_BuildFile): _DEFAULT_FILENAME = 'BUILD.gn' _INCLUDE_CONFIG_TARGET = 'public_include_path' def __init__( self, directory: Path, ctx: _ModuleContext, filename: str = _DEFAULT_FILENAME, ): super().__init__(directory / filename, ctx) def _indent_width(self) -> int: return 2 def _write_preamble(self, file: _OutputFile) -> None: # Upstream modules always require a tests target, even if it's empty. has_tests = len(self._cc_tests) > 0 or self._ctx.is_upstream imports = [] if self._cc_targets: imports.append('$dir_pw_build/target_types.gni') if has_tests: imports.append('$dir_pw_unit_test/test.gni') if self._docs_sources: imports.append('$dir_pw_docgen/docs.gni') file.line('import("//build_overrides/pigweed.gni")\n') for imp in sorted(imports): file.line(f'import("{imp}")') if self._cc_targets: file.line() _GnBuildFile._target( file, 'config', _GnBuildFile._INCLUDE_CONFIG_TARGET, { 'include_dirs': ['public'], 'visibility': [':*'], }, ) if has_tests: file.line() _GnBuildFile._target( file, 'pw_test_group', 'tests', { 'tests': list(f':{test.name}' for test in self._cc_tests), }, ) def _write_cc_target( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: """Defines a GN source_set for a C++ target.""" target_vars: _GnScope = {} if target.headers: target_vars['public_configs'] = [ f':{_GnBuildFile._INCLUDE_CONFIG_TARGET}' ] target_vars['public'] = list(target.rebased_headers(self.dir)) if target.sources: target_vars['sources'] = list(target.rebased_sources(self.dir)) if target.deps: target_vars['deps'] = target.deps _GnBuildFile._target(file, 'pw_source_set', target.name, target_vars) def _write_cc_test( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: _GnBuildFile._target( file, 'pw_test', target.name, { 'sources': list(target.rebased_sources(self.dir)), 'deps': target.deps, }, ) def _write_docs_target( self, file: _OutputFile, docs_sources: list[str], ) -> None: """Defines a pw_doc_group for module documentation.""" _GnBuildFile._target( file, 'pw_doc_group', 'docs', { 'sources': docs_sources, }, ) @staticmethod def _target( file: _OutputFile, target_type: str, name: str, args: _GnScope, ) -> None: """Formats a GN target.""" file.line(f'{target_type}("{name}") {{') with file.indent(): _GnBuildFile._format_gn_scope(file, args) file.line('}') @staticmethod def _format_gn_scope(file: _OutputFile, scope: _GnScope) -> None: """Formats all of the variables within a GN scope to a file. This function does not write the enclosing braces of the outer scope to support use from multiple formatting contexts. """ for key, val in scope.items(): if isinstance(val, int): file.line(f'{key} = {val}') continue if isinstance(val, str): file.line(f'{key} = {_GnBuildFile._gn_string(val)}') continue if isinstance(val, bool): file.line(f'{key} = {str(val).lower()}') continue if isinstance(val, dict): file.line(f'{key} = {{') with file.indent(): _GnBuildFile._format_gn_scope(file, val) file.line('}') continue # Format a list of strings. # TODO(frolv): Lists of other types? assert isinstance(val, list) if not val: file.line(f'{key} = []') continue if len(val) == 1: file.line(f'{key} = [ {_GnBuildFile._gn_string(val[0])} ]') continue file.line(f'{key} = [') with file.indent(): for string in sorted(val): file.line(f'{_GnBuildFile._gn_string(string)},') file.line(']') @staticmethod def _gn_string(string: str) -> str: """Converts a Python string into a string literal within a GN file. Accounts for the possibility of variable interpolation within GN, removing quotes if unnecessary: "string" -> "string" "string" -> "string" "$var" -> var "$var2" -> var2 "$3var" -> "$3var" "$dir_pw_foo" -> dir_pw_foo "$dir_pw_foo:bar" -> "$dir_pw_foo:bar" "$dir_pw_foo/baz" -> "$dir_pw_foo/baz" "${dir_pw_foo}" -> dir_pw_foo """ # Check if the entire string refers to a interpolated variable. # # Simple case: '$' followed a single word, e.g. "$my_variable". # Note that identifiers can't start with a number. if re.fullmatch(r'^\$[a-zA-Z_]\w*$', string): return string[1:] # GN permits wrapping an interpolated variable in braces. # Check for strings of the format "${my_variable}". if re.fullmatch(r'^\$\{[a-zA-Z_]\w*\}$', string): return string[2:-1] return f'"{string}"' class _BazelBuildFile(_BuildFile): _DEFAULT_FILENAME = 'BUILD.bazel' def __init__( self, directory: Path, ctx: _ModuleContext, filename: str = _DEFAULT_FILENAME, ): super().__init__(directory / filename, ctx) def write(self) -> None: """Writes the contents of the build file to disk.""" file = _OutputFile(self._path) file.write_template('BUILD.bazel.jinja', build=self, module=self._ctx) def _indent_width(self) -> int: return 4 # TODO(tonymd): Remove these functions once all file types are created with # templates. def _write_preamble(self, file: _OutputFile) -> None: pass def _write_cc_target( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: pass def _write_cc_test( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: pass def _write_docs_target( self, file: _OutputFile, docs_sources: list[str], ) -> None: pass class _CmakeBuildFile(_BuildFile): _DEFAULT_FILENAME = 'CMakeLists.txt' def __init__( self, directory: Path, ctx: _ModuleContext, filename: str = _DEFAULT_FILENAME, ): super().__init__(directory / filename, ctx) def write(self) -> None: """Writes the contents of the build file to disk.""" file = _OutputFile(self._path) file.write_template( 'CMakeLists.txt.jinja', build=self, module=self._ctx ) def _indent_width(self) -> int: return 2 # TODO(tonymd): Remove these functions once all file types are created with # templates. def _write_preamble(self, file: _OutputFile) -> None: pass def _write_cc_target( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: pass def _write_cc_test( self, file: _OutputFile, target: _BuildFile.CcTarget, ) -> None: pass def _write_docs_target( self, file: _OutputFile, docs_sources: list[str], ) -> None: pass class _LanguageGenerator: """Generates files for a programming language in a new Pigweed module.""" def __init__(self, ctx: _ModuleContext) -> None: self._ctx = ctx @abc.abstractmethod def create_source_files(self) -> None: """Creates the boilerplate source files required by the language.""" class _CcLanguageGenerator(_LanguageGenerator): """Generates boilerplate source files for a C++ module.""" def __init__(self, ctx: _ModuleContext) -> None: super().__init__(ctx) self._public_dir = ctx.dir / 'public' self._headers_dir = self._public_dir / ctx.name.full def create_source_files(self) -> None: self._headers_dir.mkdir(parents=True, exist_ok=True) main_header = self._new_header(self._ctx.name.main) main_source = self._new_source(self._ctx.name.main) test_source = self._new_source(f'{self._ctx.name.main}_test') # TODO(frolv): This could be configurable. namespace = self._ctx.name.default_namespace main_source.line( f'#include "{main_header.path.relative_to(self._public_dir)}"\n' ) main_source.line(f'namespace {namespace} {{\n') main_source.line('int magic = 42;\n') main_source.line(f'}} // namespace {namespace}') main_header.line(f'namespace {namespace} {{\n') main_header.line('extern int magic;\n') main_header.line(f'}} // namespace {namespace}') test_source.line( f'#include "{main_header.path.relative_to(self._public_dir)}"\n' ) test_source.line('#include "pw_unit_test/framework.h"\n') test_source.line(f'namespace {namespace} {{') test_source.line('namespace {\n') test_source.line( f'TEST({self._ctx.name.upper_camel_case()}, GeneratesCorrectly) {{' ) with test_source.indent(): test_source.line('EXPECT_EQ(magic, 42);') test_source.line('}\n') test_source.line('} // namespace') test_source.line(f'}} // namespace {namespace}') self._ctx.add_cc_target( _BuildFile.CcTarget( name=self._ctx.name.full, sources=[main_source.path], headers=[main_header.path], ) ) self._ctx.add_cc_test( _BuildFile.CcTarget( name=f'{self._ctx.name.main}_test', deps=[f':{self._ctx.name.full}'], sources=[test_source.path], ) ) main_header.write() main_source.write() test_source.write() def _new_source(self, name: str) -> _OutputFile: file = _OutputFile(self._ctx.dir / f'{name}.cc') if self._ctx.is_upstream: file.line(_PIGWEED_LICENSE_CC) file.line() return file def _new_header(self, name: str) -> _OutputFile: file = _OutputFile(self._headers_dir / f'{name}.h') if self._ctx.is_upstream: file.line(_PIGWEED_LICENSE_CC) file.line('#pragma once\n') return file _BUILD_FILES: dict[str, Type[_BuildFile]] = { 'bazel': _BazelBuildFile, 'cmake': _CmakeBuildFile, 'gn': _GnBuildFile, } _LANGUAGE_GENERATORS: dict[str, Type[_LanguageGenerator]] = { 'cc': _CcLanguageGenerator, } def _check_module_name( module: str, is_upstream: bool, ) -> _ModuleName | None: """Checks whether a module name is valid.""" name = _ModuleName.parse(module) if not name: _LOG.error( '"%s" does not conform to the Pigweed module name format', module ) return None if is_upstream and name.prefix != 'pw': _LOG.error('Modules within Pigweed itself must start with "pw_"') return None return name def _create_main_docs_file(ctx: _ModuleContext) -> None: """Populates the top-level docs.rst file within a new module.""" template = get_template('docs.rst.jinja') rendered_template = template.render(module=ctx) docs_file = _OutputFile(ctx.dir / 'docs.rst') ctx.add_docs_file(docs_file.path) docs_file.write(content=rendered_template) def _basic_module_setup( module_name: _ModuleName, module_dir: Path, build_systems: Iterable[str], is_upstream: bool, ) -> _ModuleContext: """Creates the basic layout of a Pigweed module.""" module_dir.mkdir(parents=True, exist_ok=True) public_dir = module_dir / 'public' / module_name.full public_dir.mkdir(parents=True, exist_ok=True) ctx = _ModuleContext( name=module_name, dir=module_dir, root_build_files=[], sub_build_files=[], build_systems=list(build_systems), is_upstream=is_upstream, ) ctx.root_build_files.extend( _BUILD_FILES[build](module_dir, ctx) for build in ctx.build_systems ) _create_main_docs_file(ctx) return ctx def _add_to_module_metadata( project_root: Path, module_name: _ModuleName, languages: Iterable[str] | None = None, ) -> None: """Update sphinx module metadata.""" module_metadata_file = project_root / 'docs/module_metadata.json' metadata_dict = json.loads(module_metadata_file.read_text()) language_tags = [] if languages: for lang in languages: if lang == 'cc': language_tags.append('C++') # Add the new entry if it doesn't exist if module_name.full not in metadata_dict: metadata_dict[module_name.full] = dict( status='experimental', languages=language_tags, ) # Sort by module name. sorted_metadata = dict( sorted(metadata_dict.items(), key=lambda item: item[0]) ) output_text = json.dumps(sorted_metadata, sort_keys=False, indent=2) output_text += '\n' # Write the file. if _prompt_overwrite(module_metadata_file, new_contents=output_text): _report_write_file(module_metadata_file) module_metadata_file.write_text(output_text) def _add_to_pigweed_modules_file( project_root: Path, module_name: _ModuleName, ) -> None: modules_file = project_root / 'PIGWEED_MODULES' if not modules_file.exists(): _LOG.error( 'Could not locate PIGWEED_MODULES file; ' 'your repository may be in a bad state.' ) return modules_gni_file = ( project_root / 'pw_build' / 'generated_pigweed_modules_lists.gni' ) # Cut off the extra newline at the end of the file. modules_list = modules_file.read_text().splitlines() if module_name.path in modules_list: _report_unchanged_file(modules_file) return modules_list.append(module_name.path) modules_list.sort() modules_list.append('') modules_file.write_text('\n'.join(modules_list)) _report_edited_file(modules_file) generate_modules_lists.main( root=project_root, modules_list=modules_file, modules_gni_file=modules_gni_file, mode=generate_modules_lists.Mode.UPDATE, ) _report_edited_file(modules_gni_file) def _add_to_root_cmakelists( project_root: Path, module_name: _ModuleName, ) -> None: new_line = f'add_subdirectory({module_name.path} EXCLUDE_FROM_ALL)\n' path = project_root / 'CMakeLists.txt' if not path.exists(): _LOG.error('Could not locate root CMakeLists.txt file.') return lines = path.read_text().splitlines(keepends=True) if new_line in lines: _report_unchanged_file(path) return add_subdir_start = 0 while add_subdir_start < len(lines): if lines[add_subdir_start].startswith('add_subdirectory'): break add_subdir_start += 1 insert_point = add_subdir_start while ( lines[insert_point].startswith('add_subdirectory') and lines[insert_point] < new_line ): insert_point += 1 lines.insert(insert_point, new_line) path.write_text(''.join(lines)) _report_edited_file(path) def _project_root() -> Path: """Returns the path to the root directory of the current project.""" project_root = _PW_ENV.PW_PROJECT_ROOT if not project_root.is_dir(): _LOG.error( 'Expected env var $PW_PROJECT_ROOT to point to a directory, but ' 'found `%s` which is not a directory.', project_root, ) sys.exit(1) return project_root def _is_upstream() -> bool: """Returns whether this command is being run within Pigweed itself.""" return _PW_ROOT == _project_root() _COMMENTS = re.compile(r'\w*#.*$') def _read_root_owners(project_root: Path) -> Iterable[str]: for line in (project_root / 'OWNERS').read_text().splitlines(): line = _COMMENTS.sub('', line).strip() if line: yield line def _create_module( module: str, languages: Iterable[str], build_systems: Iterable[str], owners: Collection[str] | None = None, ) -> None: project_root = _project_root() is_upstream = _is_upstream() module_name = _check_module_name(module, is_upstream) if not module_name: sys.exit(1) if not is_upstream: _LOG.error( '`pw module create` is experimental and does ' 'not yet support downstream projects.' ) sys.exit(1) module_dir = project_root / module if module_dir.is_dir(): _REPORT.wrn(f'Directory {module} already exists.') if _prompt_user('Continue?') == PromptChoice.NO: sys.exit(1) if module_dir.is_file(): _LOG.error( 'Cannot create module %s as a file of that name already exists', module, ) sys.exit(1) if owners is not None: if len(owners) < 2: _LOG.error( 'New modules must have at least two owners, but only `%s` was ' 'provided.', owners, ) sys.exit(1) for owner in owners: if '@' not in owner: _LOG.error( 'Owners should be email addresses, but found `%s`', owner ) sys.exit(1) root_owners = list(_read_root_owners(project_root)) if not any(owner in root_owners for owner in owners): root_owners_str = '\n'.join(root_owners) _LOG.error( 'Module owners must include at least one root owner, but only ' '`%s` was provided. Root owners include:\n%s', owners, root_owners_str, ) sys.exit(1) ctx = _basic_module_setup( module_name, module_dir, build_systems, is_upstream ) if owners is not None: owners_file = module_dir / 'OWNERS' owners_text = '\n'.join(sorted(owners)) owners_text += '\n' if _prompt_overwrite(owners_file, new_contents=owners_text): _report_write_file(owners_file) owners_file.write_text(owners_text) try: generators = list(_LANGUAGE_GENERATORS[lang](ctx) for lang in languages) except KeyError as key: _LOG.error('Unsupported language: %s', key) sys.exit(1) for generator in generators: generator.create_source_files() for build_file in ctx.build_files(): build_file.write() if is_upstream: _add_to_pigweed_modules_file(project_root, module_name) _add_to_module_metadata(project_root, module_name, languages) if 'cmake' in build_systems: _add_to_root_cmakelists(project_root, module_name) print() _REPORT.new(f'{module_name} created at: {module_dir.relative_to(_PW_ROOT)}') def register_subcommand(parser: argparse.ArgumentParser) -> None: """Registers the module `create` subcommand with `parser`.""" def csv(s): return s.split(",") def csv_with_choices(choices: list[str], string) -> list[str]: chosen_items = list(string.split(',')) invalid_items = set(chosen_items) - set(choices) if invalid_items: raise argparse.ArgumentTypeError( '\n' f' invalid items: [ {", ".join(invalid_items)} ].\n' f' choose from: [ {", ".join(choices)} ]' ) return chosen_items parser.add_argument( '--build-systems', help=( 'Comma-separated list of build systems the module supports. ' f'Options: {", ".join(_BUILD_FILES.keys())}' ), default=_BUILD_FILES.keys(), type=functools.partial(csv_with_choices, _BUILD_FILES.keys()), ) parser.add_argument( '--languages', help=( 'Comma-separated list of languages the module will use. ' f'Options: {", ".join(_LANGUAGE_GENERATORS.keys())}' ), default=[], type=functools.partial(csv_with_choices, _LANGUAGE_GENERATORS.keys()), ) if _is_upstream(): parser.add_argument( '--owners', help=( 'Comma-separated list of emails of the people who will own and ' 'maintain the new module. This list must contain at least two ' 'entries, and at least one user must be a top-level OWNER ' f'(listed in `{_project_root()}/OWNERS`).' ), required=True, metavar='firstownername@google.com,secondownername@foo.net', type=csv, ) parser.add_argument( 'module', help='Name of the module to create.', metavar='MODULE_NAME' ) parser.set_defaults(func=_create_module)