xref: /aosp_15_r20/external/pigweed/pw_module/py/pw_module/check.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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"""Checks a Pigweed module's format and structure."""
15
16import argparse
17import logging
18import pathlib
19import glob
20from enum import Enum
21from typing import Callable, NamedTuple, Sequence
22
23_LOG = logging.getLogger(__name__)
24
25CheckerFunction = Callable[[str], None]
26
27
28def check_modules(modules: Sequence[str]) -> int:
29    if len(modules) > 1:
30        _LOG.info('Checking %d modules', len(modules))
31
32    passed = 0
33
34    for path in modules:
35        if len(modules) > 1:
36            print()
37            print(f' {path} '.center(80, '='))
38
39        passed += check_module(path)
40
41    if len(modules) > 1:
42        _LOG.info('%d of %d modules passed', passed, len(modules))
43
44    return 0 if passed == len(modules) else 1
45
46
47def check_module(module) -> bool:
48    """Runs module checks on one module; returns True if the module passes."""
49
50    if not pathlib.Path(module).is_dir():
51        _LOG.error('No directory found: %s', module)
52        return False
53
54    found_any_warnings = False
55    found_any_errors = False
56
57    _LOG.info('Checking module: %s', module)
58    # Run each checker.
59    for check in _checkers:
60        _LOG.debug(
61            'Running checker: %s - %s',
62            check.name,
63            check.description,
64        )
65        issues = list(check.run(module))
66
67        # Log any issues found
68        for issue in issues:
69            if issue.severity == Severity.ERROR:
70                log_level = logging.ERROR
71                found_any_errors = True
72            elif issue.severity == Severity.WARNING:
73                log_level = logging.WARNING
74                found_any_warnings = True
75
76            # Try to make an error message that will help editors open the part
77            # of the module in question (e.g. vim's 'cerr' functionality).
78            components = [
79                x
80                for x in (
81                    issue.file,
82                    issue.line_number,
83                    issue.line_contents,
84                )
85                if x
86            ]
87            editor_error_line = ':'.join(components)
88            if editor_error_line:
89                _LOG.log(log_level, '%s', check.name)
90                print(editor_error_line, issue.message)
91            else:
92                # No per-file error to put in a "cerr" list, so just log.
93                _LOG.log(log_level, '%s: %s', check.name, issue.message)
94
95        if issues:
96            _LOG.debug('Done running checker: %s (issues found)', check.name)
97        else:
98            _LOG.debug('Done running checker: %s (OK)', check.name)
99
100    # TODO(keir): Give this a proper ASCII art treatment.
101    if not found_any_warnings and not found_any_errors:
102        _LOG.info(
103            'OK: Module %s looks good; no errors or warnings found', module
104        )
105    if found_any_errors:
106        _LOG.error('FAIL: Found errors when checking module %s', module)
107        return False
108
109    return True
110
111
112class Checker(NamedTuple):
113    name: str
114    description: str
115    run: CheckerFunction
116
117
118class Severity(Enum):
119    ERROR = 1
120    WARNING = 2
121
122
123class Issue(NamedTuple):
124    message: str
125    file: str = ''
126    line_number: str = ''
127    line_contents: str = ''
128    severity: Severity = Severity.ERROR
129
130
131_checkers = []
132
133
134def checker(pwck_id, description):
135    def inner_decorator(function):
136        _checkers.append(Checker(pwck_id, description, function))
137        return function
138
139    return inner_decorator
140
141
142@checker('PWCK001', 'If there is Python code, there is a setup.py')
143def check_python_proper_module(directory):
144    module_python_files = glob.glob(f'{directory}/**/*.py', recursive=True)
145    module_setup_py = glob.glob(f'{directory}/**/setup.py', recursive=True)
146    if module_python_files and not module_setup_py:
147        yield Issue('Python code present but no setup.py.')
148
149
150@checker('PWCK002', 'If there are C++ files, there are C++ tests')
151def check_have_cc_tests(directory):
152    module_cc_files = glob.glob(f'{directory}/**/*.cc', recursive=True)
153    module_cc_test_files = glob.glob(f'{directory}/**/*test.cc', recursive=True)
154    if module_cc_files and not module_cc_test_files:
155        yield Issue('C++ code present but no tests at all (you monster).')
156
157
158@checker('PWCK003', 'If there are Python files, there are Python tests')
159def check_have_python_tests(directory):
160    module_py_files = glob.glob(f'{directory}/**/*.py', recursive=True)
161    module_py_test_files = glob.glob(
162        f'{directory}/**/*test*.py', recursive=True
163    )
164    if module_py_files and not module_py_test_files:
165        yield Issue('Python code present but no tests (you monster).')
166
167
168@checker('PWCK004', 'There is ReST documentation (*.rst)')
169def check_has_rst_docs(directory):
170    if not glob.glob(f'{directory}/**/*.rst', recursive=True):
171        yield Issue('Missing ReST documentation; need at least e.g. "docs.rst"')
172
173
174@checker(
175    'PWCK005',
176    'If C++, have <mod>/public/<mod>/*.h or ' '<mod>/public_override/*.h',
177)
178def check_has_public_or_override_headers(directory):
179    # TODO(keir): Should likely have a decorator to check for C++ in a checker,
180    # or other more useful and cachable mechanisms.
181    if not glob.glob(f'{directory}/**/*.cc', recursive=True) and not glob.glob(
182        f'{directory}/**/*.h', recursive=True
183    ):
184        # No C++ files.
185        return
186
187    module_name = pathlib.Path(directory).name
188
189    has_public_cpp_headers = glob.glob(f'{directory}/public/{module_name}/*.h')
190    has_public_cpp_override_headers = glob.glob(
191        f'{directory}/public_overrides/**/*.h'
192    )
193
194    if not has_public_cpp_headers and not has_public_cpp_override_headers:
195        yield Issue(
196            f'Have C++ code but no public/{module_name}/*.h '
197            'found and no public_overrides/ found'
198        )
199
200    multiple_public_directories = glob.glob(f'{directory}/public/*')
201    if len(multiple_public_directories) != 1:
202        yield Issue(
203            f'Have multiple directories under public/; there should '
204            f'only be a single directory: "public/{module_name}". '
205            'Perhaps you were looking for public_overrides/?.'
206        )
207
208
209def register_subcommand(parser: argparse.ArgumentParser) -> None:
210    """Check that a module matches Pigweed's module guidelines."""
211    parser.add_argument('modules', nargs='+', help='The module to check')
212    parser.set_defaults(func=check_modules)
213