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