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