aboutsummaryrefslogtreecommitdiff
path: root/pw_module/py/pw_module/create.py
diff options
context:
space:
mode:
Diffstat (limited to 'pw_module/py/pw_module/create.py')
-rw-r--r--pw_module/py/pw_module/create.py940
1 files changed, 940 insertions, 0 deletions
diff --git a/pw_module/py/pw_module/create.py b/pw_module/py/pw_module/create.py
new file mode 100644
index 000000000..d53dca03a
--- /dev/null
+++ b/pw_module/py/pw_module/create.py
@@ -0,0 +1,940 @@
+# 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."""
+
+import abc
+import argparse
+import dataclasses
+from dataclasses import dataclass
+import datetime
+import logging
+import os
+from pathlib import Path
+import re
+import sys
+
+from typing import Any, Dict, Iterable, List, Optional, Type, Union
+
+from pw_build import generate_modules_lists
+
+_LOG = logging.getLogger(__name__)
+
+_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('#', '//')
+
+
+# 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: Optional[int] = 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) -> None:
+ print(' create ' + str(self._file.relative_to(Path.cwd())))
+ self._file.write_text(self.content)
+
+ 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 = '(^[a-zA-Z]{2,})((_[a-zA-Z0-9]+)+)$'
+
+ def __init__(self, prefix: str, main: str) -> None:
+ self._prefix = prefix
+ self._main = main
+
+ @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('_'))
+
+ def __str__(self) -> str:
+ return self.full
+
+ def __repr__(self) -> str:
+ return self.full
+
+ @classmethod
+ def parse(cls, name: str) -> Optional['_ModuleName']:
+ match = re.fullmatch(_ModuleName._MODULE_NAME_REGEX, name)
+ if not match:
+ return None
+
+ return cls(match.group(1), match.group(2)[1:])
+
+
+@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)
+
+ 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 = Union[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 _indent_width(self) -> int:
+ return 4
+
+ def _write_preamble(self, file: _OutputFile) -> None:
+ imports = ['//pw_build:pigweed.bzl']
+ if self._cc_targets:
+ imports.append('pw_cc_library')
+
+ if self._cc_tests:
+ imports.append('pw_cc_test')
+
+ file.line('load(')
+ with file.indent():
+ for imp in sorted(imports):
+ file.line(f'"{imp}",')
+ file.line(')\n')
+
+ file.line('package(default_visibility = ["//visibility:public"])\n')
+ file.line('licenses(["notice"])')
+
+ def _write_cc_target(
+ self,
+ file: _OutputFile,
+ target: _BuildFile.CcTarget,
+ ) -> None:
+ _BazelBuildFile._target(
+ file,
+ 'pw_cc_library',
+ target.name,
+ {
+ 'srcs': list(target.rebased_sources(self.dir)),
+ 'hdrs': list(target.rebased_headers(self.dir)),
+ 'includes': ['public'],
+ },
+ )
+
+ def _write_cc_test(
+ self,
+ file: _OutputFile,
+ target: '_BuildFile.CcTarget',
+ ) -> None:
+ _BazelBuildFile._target(
+ file,
+ 'pw_cc_test',
+ target.name,
+ {
+ 'srcs': list(target.rebased_sources(self.dir)),
+ 'deps': target.deps,
+ },
+ )
+
+ def _write_docs_target(
+ self,
+ file: _OutputFile,
+ docs_sources: List[str],
+ ) -> None:
+ file.line('# Bazel does not yet support building docs.')
+ _BazelBuildFile._target(
+ file, 'filegroup', 'docs', {'srcs': docs_sources}
+ )
+
+ @staticmethod
+ def _target(
+ file: _OutputFile,
+ target_type: str,
+ name: str,
+ keys: Dict[str, List[str]],
+ ) -> None:
+ file.line(f'{target_type}(')
+
+ with file.indent():
+ file.line(f'name = "{name}",')
+
+ for k, vals in keys.items():
+ if len(vals) == 1:
+ file.line(f'{k} = ["{vals[0]}"],')
+ continue
+
+ file.line(f'{k} = [')
+ with file.indent():
+ for val in sorted(vals):
+ file.line(f'"{val}",')
+ file.line('],')
+
+ file.line(')')
+
+
+class _CmakeBuildFile(_BuildFile):
+ _DEFAULT_FILENAME = 'CMakeLists.txt'
+
+ 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:
+ file.line('include($ENV{PW_ROOT}/pw_build/pigweed.cmake)')
+
+ def _write_cc_target(
+ self,
+ file: _OutputFile,
+ target: _BuildFile.CcTarget,
+ ) -> None:
+ if target.name == self._ctx.name.full:
+ target_name = target.name
+ else:
+ target_name = f'{self._ctx.name.full}.{target.name}'
+
+ _CmakeBuildFile._target(
+ file,
+ 'pw_add_module_library',
+ target_name,
+ {
+ 'sources': list(target.rebased_sources(self.dir)),
+ 'headers': list(target.rebased_headers(self.dir)),
+ 'public_includes': ['public'],
+ },
+ )
+
+ def _write_cc_test(
+ self,
+ file: _OutputFile,
+ target: '_BuildFile.CcTarget',
+ ) -> None:
+ _CmakeBuildFile._target(
+ file,
+ 'pw_auto_add_module_tests',
+ self._ctx.name.full,
+ {'private_deps': []},
+ )
+
+ def _write_docs_target(
+ self,
+ file: _OutputFile,
+ docs_sources: List[str],
+ ) -> None:
+ file.line('# CMake does not yet support building docs.')
+
+ @staticmethod
+ def _target(
+ file: _OutputFile,
+ target_type: str,
+ name: str,
+ keys: Dict[str, List[str]],
+ ) -> None:
+ file.line(f'{target_type}({name}')
+
+ with file.indent():
+ for k, vals in keys.items():
+ file.line(k.upper())
+ with file.indent():
+ for val in sorted(vals):
+ file.line(val)
+
+ file.line(')')
+
+
+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)
+
+ 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 "gtest/gtest.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,
+) -> Optional[_ModuleName]:
+ """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."""
+
+ docs_file = _OutputFile(ctx.dir / 'docs.rst')
+ docs_file.line(f'.. _module-{ctx.name}:\n')
+
+ title = '=' * len(ctx.name.full)
+ docs_file.line(title)
+ docs_file.line(ctx.name.full)
+ docs_file.line(title)
+ docs_file.line(f'This is the main documentation file for {ctx.name}.')
+
+ ctx.add_docs_file(docs_file.path)
+
+ docs_file.write()
+
+
+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()
+
+ 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 _create_module(
+ module: str, languages: Iterable[str], build_systems: Iterable[str]
+) -> None:
+ project_root = Path(os.environ.get('PW_PROJECT_ROOT', ''))
+ assert project_root.is_dir()
+
+ is_upstream = os.environ.get('PW_ROOT') == str(project_root)
+
+ 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():
+ _LOG.error('Module %s already exists', module)
+ 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)
+
+ ctx = _basic_module_setup(
+ module_name, module_dir, build_systems, is_upstream
+ )
+
+ 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:
+ 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().split('\n')[:-1]
+ modules_list.append(module_name.full)
+ modules_list.sort()
+ modules_list.append('')
+ modules_file.write_text('\n'.join(modules_list))
+ print(' modify ' + str(modules_file.relative_to(Path.cwd())))
+
+ generate_modules_lists.main(
+ root=project_root,
+ modules_list=modules_file,
+ modules_gni_file=modules_gni_file,
+ mode=generate_modules_lists.Mode.UPDATE,
+ )
+ print(' modify ' + str(modules_gni_file.relative_to(Path.cwd())))
+
+ print()
+ _LOG.info(
+ 'Module %s created at %s',
+ module_name,
+ module_dir.relative_to(Path.cwd()),
+ )
+
+
+def register_subcommand(parser: argparse.ArgumentParser) -> None:
+ csv = lambda s: s.split(',')
+
+ parser.add_argument(
+ '--build-systems',
+ help=(
+ 'Comma-separated list of build systems the module supports. '
+ f'Options: {", ".join(_BUILD_FILES.keys())}'
+ ),
+ type=csv,
+ default=_BUILD_FILES.keys(),
+ metavar='BUILD[,BUILD,...]',
+ )
+ parser.add_argument(
+ '--languages',
+ help=(
+ 'Comma-separated list of languages the module will use. '
+ f'Options: {", ".join(_LANGUAGE_GENERATORS.keys())}'
+ ),
+ type=csv,
+ default=[],
+ metavar='LANG[,LANG,...]',
+ )
+ parser.add_argument(
+ 'module', help='Name of the module to create.', metavar='MODULE_NAME'
+ )
+ parser.set_defaults(func=_create_module)