• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Creates a new Pigweed module."""
15
16import abc
17import argparse
18import dataclasses
19from dataclasses import dataclass
20import datetime
21import logging
22import os
23from pathlib import Path
24import re
25import sys
26
27from typing import Any, Dict, Iterable, List, Optional, Type, Union
28
29from pw_build import generate_modules_lists
30
31_LOG = logging.getLogger(__name__)
32
33_PIGWEED_LICENSE = f"""
34# Copyright {datetime.datetime.now().year} The Pigweed Authors
35#
36# Licensed under the Apache License, Version 2.0 (the "License"); you may not
37# use this file except in compliance with the License. You may obtain a copy of
38# the License at
39#
40#     https://www.apache.org/licenses/LICENSE-2.0
41#
42# Unless required by applicable law or agreed to in writing, software
43# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
44# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
45# License for the specific language governing permissions and limitations under
46# the License.""".lstrip()
47
48_PIGWEED_LICENSE_CC = _PIGWEED_LICENSE.replace('#', '//')
49
50
51# TODO(frolv): Adapted from pw_protobuf. Consolidate them.
52class _OutputFile:
53    DEFAULT_INDENT_WIDTH = 2
54
55    def __init__(self, file: Path, indent_width: int = DEFAULT_INDENT_WIDTH):
56        self._file = file
57        self._content: List[str] = []
58        self._indent_width: int = indent_width
59        self._indentation = 0
60
61    def line(self, line: str = '') -> None:
62        if line:
63            self._content.append(' ' * self._indentation)
64            self._content.append(line)
65        self._content.append('\n')
66
67    def indent(
68        self,
69        width: Optional[int] = None,
70    ) -> '_OutputFile._IndentationContext':
71        """Increases the indentation level of the output."""
72        return self._IndentationContext(
73            self, width if width is not None else self._indent_width
74        )
75
76    @property
77    def path(self) -> Path:
78        return self._file
79
80    @property
81    def content(self) -> str:
82        return ''.join(self._content)
83
84    def write(self) -> None:
85        print('  create  ' + str(self._file.relative_to(Path.cwd())))
86        self._file.write_text(self.content)
87
88    class _IndentationContext:
89        """Context that increases the output's indentation when it is active."""
90
91        def __init__(self, output: '_OutputFile', width: int):
92            self._output = output
93            self._width: int = width
94
95        def __enter__(self):
96            self._output._indentation += self._width
97
98        def __exit__(self, typ, value, traceback):
99            self._output._indentation -= self._width
100
101
102class _ModuleName:
103    _MODULE_NAME_REGEX = '(^[a-zA-Z]{2,})((_[a-zA-Z0-9]+)+)$'
104
105    def __init__(self, prefix: str, main: str) -> None:
106        self._prefix = prefix
107        self._main = main
108
109    @property
110    def full(self) -> str:
111        return f'{self._prefix}_{self._main}'
112
113    @property
114    def prefix(self) -> str:
115        return self._prefix
116
117    @property
118    def main(self) -> str:
119        return self._main
120
121    @property
122    def default_namespace(self) -> str:
123        return f'{self._prefix}::{self._main}'
124
125    def upper_camel_case(self) -> str:
126        return ''.join(s.capitalize() for s in self._main.split('_'))
127
128    def __str__(self) -> str:
129        return self.full
130
131    def __repr__(self) -> str:
132        return self.full
133
134    @classmethod
135    def parse(cls, name: str) -> Optional['_ModuleName']:
136        match = re.fullmatch(_ModuleName._MODULE_NAME_REGEX, name)
137        if not match:
138            return None
139
140        return cls(match.group(1), match.group(2)[1:])
141
142
143@dataclass
144class _ModuleContext:
145    name: _ModuleName
146    dir: Path
147    root_build_files: List['_BuildFile']
148    sub_build_files: List['_BuildFile']
149    build_systems: List[str]
150    is_upstream: bool
151
152    def build_files(self) -> Iterable['_BuildFile']:
153        yield from self.root_build_files
154        yield from self.sub_build_files
155
156    def add_docs_file(self, file: Path):
157        for build_file in self.root_build_files:
158            build_file.add_docs_source(str(file.relative_to(self.dir)))
159
160    def add_cc_target(self, target: '_BuildFile.CcTarget') -> None:
161        for build_file in self.root_build_files:
162            build_file.add_cc_target(target)
163
164    def add_cc_test(self, target: '_BuildFile.CcTarget') -> None:
165        for build_file in self.root_build_files:
166            build_file.add_cc_test(target)
167
168
169class _BuildFile:
170    """Abstract representation of a build file for a module."""
171
172    @dataclass
173    class Target:
174        name: str
175
176        # TODO(frolv): Shouldn't be a string list as that's build system
177        # specific. Figure out a way to resolve dependencies from targets.
178        deps: List[str] = dataclasses.field(default_factory=list)
179
180    @dataclass
181    class CcTarget(Target):
182        sources: List[Path] = dataclasses.field(default_factory=list)
183        headers: List[Path] = dataclasses.field(default_factory=list)
184
185        def rebased_sources(self, rebase_path: Path) -> Iterable[str]:
186            return (str(src.relative_to(rebase_path)) for src in self.sources)
187
188        def rebased_headers(self, rebase_path: Path) -> Iterable[str]:
189            return (str(hdr.relative_to(rebase_path)) for hdr in self.headers)
190
191    def __init__(self, path: Path, ctx: _ModuleContext):
192        self._path = path
193        self._ctx = ctx
194
195        self._docs_sources: List[str] = []
196        self._cc_targets: List[_BuildFile.CcTarget] = []
197        self._cc_tests: List[_BuildFile.CcTarget] = []
198
199    @property
200    def path(self) -> Path:
201        return self._path
202
203    @property
204    def dir(self) -> Path:
205        return self._path.parent
206
207    def add_docs_source(self, filename: str) -> None:
208        self._docs_sources.append(filename)
209
210    def add_cc_target(self, target: CcTarget) -> None:
211        self._cc_targets.append(target)
212
213    def add_cc_test(self, target: CcTarget) -> None:
214        self._cc_tests.append(target)
215
216    def write(self) -> None:
217        """Writes the contents of the build file to disk."""
218        file = _OutputFile(self._path, self._indent_width())
219
220        if self._ctx.is_upstream:
221            file.line(_PIGWEED_LICENSE)
222            file.line()
223
224        self._write_preamble(file)
225
226        for target in self._cc_targets:
227            file.line()
228            self._write_cc_target(file, target)
229
230        for target in self._cc_tests:
231            file.line()
232            self._write_cc_test(file, target)
233
234        if self._docs_sources:
235            file.line()
236            self._write_docs_target(file, self._docs_sources)
237
238        file.write()
239
240    @abc.abstractmethod
241    def _indent_width(self) -> int:
242        """Returns the default indent width for the build file's code style."""
243
244    @abc.abstractmethod
245    def _write_preamble(self, file: _OutputFile) -> None:
246        """Formats"""
247
248    @abc.abstractmethod
249    def _write_cc_target(
250        self,
251        file: _OutputFile,
252        target: '_BuildFile.CcTarget',
253    ) -> None:
254        """Defines a C++ library target within the build file."""
255
256    @abc.abstractmethod
257    def _write_cc_test(
258        self,
259        file: _OutputFile,
260        target: '_BuildFile.CcTarget',
261    ) -> None:
262        """Defines a C++ unit test target within the build file."""
263
264    @abc.abstractmethod
265    def _write_docs_target(
266        self,
267        file: _OutputFile,
268        docs_sources: List[str],
269    ) -> None:
270        """Defines a documentation target within the build file."""
271
272
273# TODO(frolv): The Dict here should be Dict[str, '_GnVal'] (i.e. _GnScope),
274# but mypy does not yet support recursive types:
275# https://github.com/python/mypy/issues/731
276_GnVal = Union[bool, int, str, List[str], Dict[str, Any]]
277_GnScope = Dict[str, _GnVal]
278
279
280class _GnBuildFile(_BuildFile):
281    _DEFAULT_FILENAME = 'BUILD.gn'
282    _INCLUDE_CONFIG_TARGET = 'public_include_path'
283
284    def __init__(
285        self,
286        directory: Path,
287        ctx: _ModuleContext,
288        filename: str = _DEFAULT_FILENAME,
289    ):
290        super().__init__(directory / filename, ctx)
291
292    def _indent_width(self) -> int:
293        return 2
294
295    def _write_preamble(self, file: _OutputFile) -> None:
296        # Upstream modules always require a tests target, even if it's empty.
297        has_tests = len(self._cc_tests) > 0 or self._ctx.is_upstream
298
299        imports = []
300
301        if self._cc_targets:
302            imports.append('$dir_pw_build/target_types.gni')
303
304        if has_tests:
305            imports.append('$dir_pw_unit_test/test.gni')
306
307        if self._docs_sources:
308            imports.append('$dir_pw_docgen/docs.gni')
309
310        file.line('import("//build_overrides/pigweed.gni")\n')
311        for imp in sorted(imports):
312            file.line(f'import("{imp}")')
313
314        if self._cc_targets:
315            file.line()
316            _GnBuildFile._target(
317                file,
318                'config',
319                _GnBuildFile._INCLUDE_CONFIG_TARGET,
320                {
321                    'include_dirs': ['public'],
322                    'visibility': [':*'],
323                },
324            )
325
326        if has_tests:
327            file.line()
328            _GnBuildFile._target(
329                file,
330                'pw_test_group',
331                'tests',
332                {
333                    'tests': list(f':{test.name}' for test in self._cc_tests),
334                },
335            )
336
337    def _write_cc_target(
338        self,
339        file: _OutputFile,
340        target: _BuildFile.CcTarget,
341    ) -> None:
342        """Defines a GN source_set for a C++ target."""
343
344        target_vars: _GnScope = {}
345
346        if target.headers:
347            target_vars['public_configs'] = [
348                f':{_GnBuildFile._INCLUDE_CONFIG_TARGET}'
349            ]
350            target_vars['public'] = list(target.rebased_headers(self.dir))
351
352        if target.sources:
353            target_vars['sources'] = list(target.rebased_sources(self.dir))
354
355        if target.deps:
356            target_vars['deps'] = target.deps
357
358        _GnBuildFile._target(file, 'pw_source_set', target.name, target_vars)
359
360    def _write_cc_test(
361        self,
362        file: _OutputFile,
363        target: '_BuildFile.CcTarget',
364    ) -> None:
365        _GnBuildFile._target(
366            file,
367            'pw_test',
368            target.name,
369            {
370                'sources': list(target.rebased_sources(self.dir)),
371                'deps': target.deps,
372            },
373        )
374
375    def _write_docs_target(
376        self,
377        file: _OutputFile,
378        docs_sources: List[str],
379    ) -> None:
380        """Defines a pw_doc_group for module documentation."""
381        _GnBuildFile._target(
382            file,
383            'pw_doc_group',
384            'docs',
385            {
386                'sources': docs_sources,
387            },
388        )
389
390    @staticmethod
391    def _target(
392        file: _OutputFile,
393        target_type: str,
394        name: str,
395        args: _GnScope,
396    ) -> None:
397        """Formats a GN target."""
398
399        file.line(f'{target_type}("{name}") {{')
400
401        with file.indent():
402            _GnBuildFile._format_gn_scope(file, args)
403
404        file.line('}')
405
406    @staticmethod
407    def _format_gn_scope(file: _OutputFile, scope: _GnScope) -> None:
408        """Formats all of the variables within a GN scope to a file.
409
410        This function does not write the enclosing braces of the outer scope to
411        support use from multiple formatting contexts.
412        """
413        for key, val in scope.items():
414            if isinstance(val, int):
415                file.line(f'{key} = {val}')
416                continue
417
418            if isinstance(val, str):
419                file.line(f'{key} = {_GnBuildFile._gn_string(val)}')
420                continue
421
422            if isinstance(val, bool):
423                file.line(f'{key} = {str(val).lower()}')
424                continue
425
426            if isinstance(val, dict):
427                file.line(f'{key} = {{')
428                with file.indent():
429                    _GnBuildFile._format_gn_scope(file, val)
430                file.line('}')
431                continue
432
433            # Format a list of strings.
434            # TODO(frolv): Lists of other types?
435            assert isinstance(val, list)
436
437            if not val:
438                file.line(f'{key} = []')
439                continue
440
441            if len(val) == 1:
442                file.line(f'{key} = [ {_GnBuildFile._gn_string(val[0])} ]')
443                continue
444
445            file.line(f'{key} = [')
446            with file.indent():
447                for string in sorted(val):
448                    file.line(f'{_GnBuildFile._gn_string(string)},')
449            file.line(']')
450
451    @staticmethod
452    def _gn_string(string: str) -> str:
453        """Converts a Python string into a string literal within a GN file.
454
455        Accounts for the possibility of variable interpolation within GN,
456        removing quotes if unnecessary:
457
458            "string"           ->  "string"
459            "string"           ->  "string"
460            "$var"             ->  var
461            "$var2"            ->  var2
462            "$3var"            ->  "$3var"
463            "$dir_pw_foo"      ->  dir_pw_foo
464            "$dir_pw_foo:bar"  ->  "$dir_pw_foo:bar"
465            "$dir_pw_foo/baz"  ->  "$dir_pw_foo/baz"
466            "${dir_pw_foo}"    ->  dir_pw_foo
467
468        """
469
470        # Check if the entire string refers to a interpolated variable.
471        #
472        # Simple case: '$' followed a single word, e.g. "$my_variable".
473        # Note that identifiers can't start with a number.
474        if re.fullmatch(r'^\$[a-zA-Z_]\w*$', string):
475            return string[1:]
476
477        # GN permits wrapping an interpolated variable in braces.
478        # Check for strings of the format "${my_variable}".
479        if re.fullmatch(r'^\$\{[a-zA-Z_]\w*\}$', string):
480            return string[2:-1]
481
482        return f'"{string}"'
483
484
485class _BazelBuildFile(_BuildFile):
486    _DEFAULT_FILENAME = 'BUILD.bazel'
487
488    def __init__(
489        self,
490        directory: Path,
491        ctx: _ModuleContext,
492        filename: str = _DEFAULT_FILENAME,
493    ):
494        super().__init__(directory / filename, ctx)
495
496    def _indent_width(self) -> int:
497        return 4
498
499    def _write_preamble(self, file: _OutputFile) -> None:
500        imports = ['//pw_build:pigweed.bzl']
501        if self._cc_targets:
502            imports.append('pw_cc_library')
503
504        if self._cc_tests:
505            imports.append('pw_cc_test')
506
507        file.line('load(')
508        with file.indent():
509            for imp in sorted(imports):
510                file.line(f'"{imp}",')
511        file.line(')\n')
512
513        file.line('package(default_visibility = ["//visibility:public"])\n')
514        file.line('licenses(["notice"])')
515
516    def _write_cc_target(
517        self,
518        file: _OutputFile,
519        target: _BuildFile.CcTarget,
520    ) -> None:
521        _BazelBuildFile._target(
522            file,
523            'pw_cc_library',
524            target.name,
525            {
526                'srcs': list(target.rebased_sources(self.dir)),
527                'hdrs': list(target.rebased_headers(self.dir)),
528                'includes': ['public'],
529            },
530        )
531
532    def _write_cc_test(
533        self,
534        file: _OutputFile,
535        target: '_BuildFile.CcTarget',
536    ) -> None:
537        _BazelBuildFile._target(
538            file,
539            'pw_cc_test',
540            target.name,
541            {
542                'srcs': list(target.rebased_sources(self.dir)),
543                'deps': target.deps,
544            },
545        )
546
547    def _write_docs_target(
548        self,
549        file: _OutputFile,
550        docs_sources: List[str],
551    ) -> None:
552        file.line('# Bazel does not yet support building docs.')
553        _BazelBuildFile._target(
554            file, 'filegroup', 'docs', {'srcs': docs_sources}
555        )
556
557    @staticmethod
558    def _target(
559        file: _OutputFile,
560        target_type: str,
561        name: str,
562        keys: Dict[str, List[str]],
563    ) -> None:
564        file.line(f'{target_type}(')
565
566        with file.indent():
567            file.line(f'name = "{name}",')
568
569            for k, vals in keys.items():
570                if len(vals) == 1:
571                    file.line(f'{k} = ["{vals[0]}"],')
572                    continue
573
574                file.line(f'{k} = [')
575                with file.indent():
576                    for val in sorted(vals):
577                        file.line(f'"{val}",')
578                file.line('],')
579
580        file.line(')')
581
582
583class _CmakeBuildFile(_BuildFile):
584    _DEFAULT_FILENAME = 'CMakeLists.txt'
585
586    def __init__(
587        self,
588        directory: Path,
589        ctx: _ModuleContext,
590        filename: str = _DEFAULT_FILENAME,
591    ):
592        super().__init__(directory / filename, ctx)
593
594    def _indent_width(self) -> int:
595        return 2
596
597    def _write_preamble(self, file: _OutputFile) -> None:
598        file.line('include($ENV{PW_ROOT}/pw_build/pigweed.cmake)')
599
600    def _write_cc_target(
601        self,
602        file: _OutputFile,
603        target: _BuildFile.CcTarget,
604    ) -> None:
605        if target.name == self._ctx.name.full:
606            target_name = target.name
607        else:
608            target_name = f'{self._ctx.name.full}.{target.name}'
609
610        _CmakeBuildFile._target(
611            file,
612            'pw_add_module_library',
613            target_name,
614            {
615                'sources': list(target.rebased_sources(self.dir)),
616                'headers': list(target.rebased_headers(self.dir)),
617                'public_includes': ['public'],
618            },
619        )
620
621    def _write_cc_test(
622        self,
623        file: _OutputFile,
624        target: '_BuildFile.CcTarget',
625    ) -> None:
626        _CmakeBuildFile._target(
627            file,
628            'pw_auto_add_module_tests',
629            self._ctx.name.full,
630            {'private_deps': []},
631        )
632
633    def _write_docs_target(
634        self,
635        file: _OutputFile,
636        docs_sources: List[str],
637    ) -> None:
638        file.line('# CMake does not yet support building docs.')
639
640    @staticmethod
641    def _target(
642        file: _OutputFile,
643        target_type: str,
644        name: str,
645        keys: Dict[str, List[str]],
646    ) -> None:
647        file.line(f'{target_type}({name}')
648
649        with file.indent():
650            for k, vals in keys.items():
651                file.line(k.upper())
652                with file.indent():
653                    for val in sorted(vals):
654                        file.line(val)
655
656        file.line(')')
657
658
659class _LanguageGenerator:
660    """Generates files for a programming language in a new Pigweed module."""
661
662    def __init__(self, ctx: _ModuleContext) -> None:
663        self._ctx = ctx
664
665    @abc.abstractmethod
666    def create_source_files(self) -> None:
667        """Creates the boilerplate source files required by the language."""
668
669
670class _CcLanguageGenerator(_LanguageGenerator):
671    """Generates boilerplate source files for a C++ module."""
672
673    def __init__(self, ctx: _ModuleContext) -> None:
674        super().__init__(ctx)
675
676        self._public_dir = ctx.dir / 'public'
677        self._headers_dir = self._public_dir / ctx.name.full
678
679    def create_source_files(self) -> None:
680        self._headers_dir.mkdir(parents=True)
681
682        main_header = self._new_header(self._ctx.name.main)
683        main_source = self._new_source(self._ctx.name.main)
684        test_source = self._new_source(f'{self._ctx.name.main}_test')
685
686        # TODO(frolv): This could be configurable.
687        namespace = self._ctx.name.default_namespace
688
689        main_source.line(
690            f'#include "{main_header.path.relative_to(self._public_dir)}"\n'
691        )
692        main_source.line(f'namespace {namespace} {{\n')
693        main_source.line('int magic = 42;\n')
694        main_source.line(f'}}  // namespace {namespace}')
695
696        main_header.line(f'namespace {namespace} {{\n')
697        main_header.line('extern int magic;\n')
698        main_header.line(f'}}  // namespace {namespace}')
699
700        test_source.line(
701            f'#include "{main_header.path.relative_to(self._public_dir)}"\n'
702        )
703        test_source.line('#include "gtest/gtest.h"\n')
704        test_source.line(f'namespace {namespace} {{')
705        test_source.line('namespace {\n')
706        test_source.line(
707            f'TEST({self._ctx.name.upper_camel_case()}, GeneratesCorrectly) {{'
708        )
709        with test_source.indent():
710            test_source.line('EXPECT_EQ(magic, 42);')
711        test_source.line('}\n')
712        test_source.line('}  // namespace')
713        test_source.line(f'}}  // namespace {namespace}')
714
715        self._ctx.add_cc_target(
716            _BuildFile.CcTarget(
717                name=self._ctx.name.full,
718                sources=[main_source.path],
719                headers=[main_header.path],
720            )
721        )
722
723        self._ctx.add_cc_test(
724            _BuildFile.CcTarget(
725                name=f'{self._ctx.name.main}_test',
726                deps=[f':{self._ctx.name.full}'],
727                sources=[test_source.path],
728            )
729        )
730
731        main_header.write()
732        main_source.write()
733        test_source.write()
734
735    def _new_source(self, name: str) -> _OutputFile:
736        file = _OutputFile(self._ctx.dir / f'{name}.cc')
737
738        if self._ctx.is_upstream:
739            file.line(_PIGWEED_LICENSE_CC)
740            file.line()
741
742        return file
743
744    def _new_header(self, name: str) -> _OutputFile:
745        file = _OutputFile(self._headers_dir / f'{name}.h')
746
747        if self._ctx.is_upstream:
748            file.line(_PIGWEED_LICENSE_CC)
749
750        file.line('#pragma once\n')
751        return file
752
753
754_BUILD_FILES: Dict[str, Type[_BuildFile]] = {
755    'bazel': _BazelBuildFile,
756    'cmake': _CmakeBuildFile,
757    'gn': _GnBuildFile,
758}
759
760_LANGUAGE_GENERATORS: Dict[str, Type[_LanguageGenerator]] = {
761    'cc': _CcLanguageGenerator,
762}
763
764
765def _check_module_name(
766    module: str,
767    is_upstream: bool,
768) -> Optional[_ModuleName]:
769    """Checks whether a module name is valid."""
770
771    name = _ModuleName.parse(module)
772    if not name:
773        _LOG.error(
774            '"%s" does not conform to the Pigweed module name format', module
775        )
776        return None
777
778    if is_upstream and name.prefix != 'pw':
779        _LOG.error('Modules within Pigweed itself must start with "pw_"')
780        return None
781
782    return name
783
784
785def _create_main_docs_file(ctx: _ModuleContext) -> None:
786    """Populates the top-level docs.rst file within a new module."""
787
788    docs_file = _OutputFile(ctx.dir / 'docs.rst')
789    docs_file.line(f'.. _module-{ctx.name}:\n')
790
791    title = '=' * len(ctx.name.full)
792    docs_file.line(title)
793    docs_file.line(ctx.name.full)
794    docs_file.line(title)
795    docs_file.line(f'This is the main documentation file for {ctx.name}.')
796
797    ctx.add_docs_file(docs_file.path)
798
799    docs_file.write()
800
801
802def _basic_module_setup(
803    module_name: _ModuleName,
804    module_dir: Path,
805    build_systems: Iterable[str],
806    is_upstream: bool,
807) -> _ModuleContext:
808    """Creates the basic layout of a Pigweed module."""
809    module_dir.mkdir()
810
811    ctx = _ModuleContext(
812        name=module_name,
813        dir=module_dir,
814        root_build_files=[],
815        sub_build_files=[],
816        build_systems=list(build_systems),
817        is_upstream=is_upstream,
818    )
819
820    ctx.root_build_files.extend(
821        _BUILD_FILES[build](module_dir, ctx) for build in ctx.build_systems
822    )
823
824    _create_main_docs_file(ctx)
825
826    return ctx
827
828
829def _create_module(
830    module: str, languages: Iterable[str], build_systems: Iterable[str]
831) -> None:
832    project_root = Path(os.environ.get('PW_PROJECT_ROOT', ''))
833    assert project_root.is_dir()
834
835    is_upstream = os.environ.get('PW_ROOT') == str(project_root)
836
837    module_name = _check_module_name(module, is_upstream)
838    if not module_name:
839        sys.exit(1)
840
841    if not is_upstream:
842        _LOG.error(
843            '`pw module create` is experimental and does '
844            'not yet support downstream projects.'
845        )
846        sys.exit(1)
847
848    module_dir = project_root / module
849
850    if module_dir.is_dir():
851        _LOG.error('Module %s already exists', module)
852        sys.exit(1)
853
854    if module_dir.is_file():
855        _LOG.error(
856            'Cannot create module %s as a file of that name already exists',
857            module,
858        )
859        sys.exit(1)
860
861    ctx = _basic_module_setup(
862        module_name, module_dir, build_systems, is_upstream
863    )
864
865    try:
866        generators = list(_LANGUAGE_GENERATORS[lang](ctx) for lang in languages)
867    except KeyError as key:
868        _LOG.error('Unsupported language: %s', key)
869        sys.exit(1)
870
871    for generator in generators:
872        generator.create_source_files()
873
874    for build_file in ctx.build_files():
875        build_file.write()
876
877    if is_upstream:
878        modules_file = project_root / 'PIGWEED_MODULES'
879        if not modules_file.exists():
880            _LOG.error(
881                'Could not locate PIGWEED_MODULES file; '
882                'your repository may be in a bad state.'
883            )
884            return
885
886        modules_gni_file = (
887            project_root / 'pw_build' / 'generated_pigweed_modules_lists.gni'
888        )
889
890        # Cut off the extra newline at the end of the file.
891        modules_list = modules_file.read_text().split('\n')[:-1]
892        modules_list.append(module_name.full)
893        modules_list.sort()
894        modules_list.append('')
895        modules_file.write_text('\n'.join(modules_list))
896        print('  modify  ' + str(modules_file.relative_to(Path.cwd())))
897
898        generate_modules_lists.main(
899            root=project_root,
900            modules_list=modules_file,
901            modules_gni_file=modules_gni_file,
902            mode=generate_modules_lists.Mode.UPDATE,
903        )
904        print('  modify  ' + str(modules_gni_file.relative_to(Path.cwd())))
905
906    print()
907    _LOG.info(
908        'Module %s created at %s',
909        module_name,
910        module_dir.relative_to(Path.cwd()),
911    )
912
913
914def register_subcommand(parser: argparse.ArgumentParser) -> None:
915    csv = lambda s: s.split(',')
916
917    parser.add_argument(
918        '--build-systems',
919        help=(
920            'Comma-separated list of build systems the module supports. '
921            f'Options: {", ".join(_BUILD_FILES.keys())}'
922        ),
923        type=csv,
924        default=_BUILD_FILES.keys(),
925        metavar='BUILD[,BUILD,...]',
926    )
927    parser.add_argument(
928        '--languages',
929        help=(
930            'Comma-separated list of languages the module will use. '
931            f'Options: {", ".join(_LANGUAGE_GENERATORS.keys())}'
932        ),
933        type=csv,
934        default=[],
935        metavar='LANG[,LANG,...]',
936    )
937    parser.add_argument(
938        'module', help='Name of the module to create.', metavar='MODULE_NAME'
939    )
940    parser.set_defaults(func=_create_module)
941