• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2020 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 for x in (
80                    issue.file,
81                    issue.line_number,
82                    issue.line_contents,
83                ) if x
84            ]
85            editor_error_line = ':'.join(components)
86            if editor_error_line:
87                _LOG.log(log_level, '%s', check.name)
88                print(editor_error_line, issue.message)
89            else:
90                # No per-file error to put in a "cerr" list, so just log.
91                _LOG.log(log_level, '%s: %s', check.name, issue.message)
92
93        if issues:
94            _LOG.debug('Done running checker: %s (issues found)', check.name)
95        else:
96            _LOG.debug('Done running checker: %s (OK)', check.name)
97
98    # TODO(keir): Give this a proper ASCII art treatment.
99    if not found_any_warnings and not found_any_errors:
100        _LOG.info('OK: Module %s looks good; no errors or warnings found',
101                  module)
102    if found_any_errors:
103        _LOG.error('FAIL: Found errors when checking module %s', module)
104        return False
105
106    return True
107
108
109class Checker(NamedTuple):
110    name: str
111    description: str
112    run: CheckerFunction
113
114
115class Severity(Enum):
116    ERROR = 1
117    WARNING = 2
118
119
120class Issue(NamedTuple):
121    message: str
122    file: str = ''
123    line_number: str = ''
124    line_contents: str = ''
125    severity: Severity = Severity.ERROR
126
127
128_checkers = []
129
130
131def checker(pwck_id, description):
132    def inner_decorator(function):
133        _checkers.append(Checker(pwck_id, description, function))
134        return function
135
136    return inner_decorator
137
138
139@checker('PWCK001', 'If there is Python code, there is a setup.py')
140def check_python_proper_module(directory):
141    module_python_files = glob.glob(f'{directory}/**/*.py', recursive=True)
142    module_setup_py = glob.glob(f'{directory}/**/setup.py', recursive=True)
143    if module_python_files and not module_setup_py:
144        yield Issue('Python code present but no setup.py.')
145
146
147@checker('PWCK002', 'If there are C++ files, there are C++ tests')
148def check_have_cc_tests(directory):
149    module_cc_files = glob.glob(f'{directory}/**/*.cc', recursive=True)
150    module_cc_test_files = glob.glob(f'{directory}/**/*test.cc',
151                                     recursive=True)
152    if module_cc_files and not module_cc_test_files:
153        yield Issue('C++ code present but no tests at all (you monster).')
154
155
156@checker('PWCK003', 'If there are Python files, there are Python tests')
157def check_have_python_tests(directory):
158    module_py_files = glob.glob(f'{directory}/**/*.py', recursive=True)
159    module_py_test_files = glob.glob(f'{directory}/**/*test*.py',
160                                     recursive=True)
161    if module_py_files and not module_py_test_files:
162        yield Issue('Python code present but no tests (you monster).')
163
164
165@checker('PWCK004', 'There is a README.md')
166def check_has_readme(directory):
167    if not glob.glob(f'{directory}/README.md'):
168        yield Issue('Missing module top-level README.md')
169
170
171@checker('PWCK005', 'There is ReST documentation (*.rst)')
172def check_has_rst_docs(directory):
173    if not glob.glob(f'{directory}/**/*.rst', recursive=True):
174        yield Issue(
175            'Missing ReST documentation; need at least e.g. "docs.rst"')
176
177
178@checker('PWCK006', 'If C++, have <mod>/public/<mod>/*.h or '
179         '<mod>/public_override/*.h')
180def check_has_public_or_override_headers(directory):
181    # TODO: Should likely have a decorator to check for C++ in a checker, or
182    # other more useful and cachable mechanisms.
183    if (not glob.glob(f'{directory}/**/*.cc', recursive=True)
184            and not glob.glob(f'{directory}/**/*.h', recursive=True)):
185        # No C++ files.
186        return
187
188    module_name = pathlib.Path(directory).name
189
190    has_public_cpp_headers = glob.glob(f'{directory}/public/{module_name}/*.h')
191    has_public_cpp_override_headers = glob.glob(
192        f'{directory}/public_overrides/**/*.h')
193
194    if not has_public_cpp_headers and not has_public_cpp_override_headers:
195        yield Issue(f'Have C++ code but no public/{module_name}/*.h '
196                    'found and no public_overrides/ found')
197
198    multiple_public_directories = glob.glob(f'{directory}/public/*')
199    if len(multiple_public_directories) != 1:
200        yield Issue(f'Have multiple directories under public/; there should '
201                    f'only be a single directory: "public/{module_name}". '
202                    'Perhaps you were looking for public_overrides/?.')
203
204
205def main() -> None:
206    """Check that a module matches Pigweed's module guidelines."""
207    parser = argparse.ArgumentParser(description=__doc__)
208    parser.add_argument('modules', nargs='+', help='The module to check')
209    check_modules(**vars(parser.parse_args()))
210