blob: e3c7b282ccff047b9ce8e5d823ee084b9eee84ea [file] [log] [blame]
# Copyright 2022 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Checks a Pigweed module's format and structure."""
import argparse
import logging
import pathlib
import glob
from enum import Enum
from typing import Callable, NamedTuple, Sequence
_LOG = logging.getLogger(__name__)
CheckerFunction = Callable[[str], None]
def check_modules(modules: Sequence[str]) -> int:
if len(modules) > 1:
_LOG.info('Checking %d modules', len(modules))
passed = 0
for path in modules:
if len(modules) > 1:
print()
print(f' {path} '.center(80, '='))
passed += check_module(path)
if len(modules) > 1:
_LOG.info('%d of %d modules passed', passed, len(modules))
return 0 if passed == len(modules) else 1
def check_module(module) -> bool:
"""Runs module checks on one module; returns True if the module passes."""
if not pathlib.Path(module).is_dir():
_LOG.error('No directory found: %s', module)
return False
found_any_warnings = False
found_any_errors = False
_LOG.info('Checking module: %s', module)
# Run each checker.
for check in _checkers:
_LOG.debug(
'Running checker: %s - %s',
check.name,
check.description,
)
issues = list(check.run(module))
# Log any issues found
for issue in issues:
if issue.severity == Severity.ERROR:
log_level = logging.ERROR
found_any_errors = True
elif issue.severity == Severity.WARNING:
log_level = logging.WARNING
found_any_warnings = True
# Try to make an error message that will help editors open the part
# of the module in question (e.g. vim's 'cerr' functionality).
components = [
x for x in (
issue.file,
issue.line_number,
issue.line_contents,
) if x
]
editor_error_line = ':'.join(components)
if editor_error_line:
_LOG.log(log_level, '%s', check.name)
print(editor_error_line, issue.message)
else:
# No per-file error to put in a "cerr" list, so just log.
_LOG.log(log_level, '%s: %s', check.name, issue.message)
if issues:
_LOG.debug('Done running checker: %s (issues found)', check.name)
else:
_LOG.debug('Done running checker: %s (OK)', check.name)
# TODO(keir): Give this a proper ASCII art treatment.
if not found_any_warnings and not found_any_errors:
_LOG.info('OK: Module %s looks good; no errors or warnings found',
module)
if found_any_errors:
_LOG.error('FAIL: Found errors when checking module %s', module)
return False
return True
class Checker(NamedTuple):
name: str
description: str
run: CheckerFunction
class Severity(Enum):
ERROR = 1
WARNING = 2
class Issue(NamedTuple):
message: str
file: str = ''
line_number: str = ''
line_contents: str = ''
severity: Severity = Severity.ERROR
_checkers = []
def checker(pwck_id, description):
def inner_decorator(function):
_checkers.append(Checker(pwck_id, description, function))
return function
return inner_decorator
@checker('PWCK001', 'If there is Python code, there is a setup.py')
def check_python_proper_module(directory):
module_python_files = glob.glob(f'{directory}/**/*.py', recursive=True)
module_setup_py = glob.glob(f'{directory}/**/setup.py', recursive=True)
if module_python_files and not module_setup_py:
yield Issue('Python code present but no setup.py.')
@checker('PWCK002', 'If there are C++ files, there are C++ tests')
def check_have_cc_tests(directory):
module_cc_files = glob.glob(f'{directory}/**/*.cc', recursive=True)
module_cc_test_files = glob.glob(f'{directory}/**/*test.cc',
recursive=True)
if module_cc_files and not module_cc_test_files:
yield Issue('C++ code present but no tests at all (you monster).')
@checker('PWCK003', 'If there are Python files, there are Python tests')
def check_have_python_tests(directory):
module_py_files = glob.glob(f'{directory}/**/*.py', recursive=True)
module_py_test_files = glob.glob(f'{directory}/**/*test*.py',
recursive=True)
if module_py_files and not module_py_test_files:
yield Issue('Python code present but no tests (you monster).')
@checker('PWCK004', 'There is a README.md')
def check_has_readme(directory):
if not glob.glob(f'{directory}/README.md'):
yield Issue('Missing module top-level README.md')
@checker('PWCK005', 'There is ReST documentation (*.rst)')
def check_has_rst_docs(directory):
if not glob.glob(f'{directory}/**/*.rst', recursive=True):
yield Issue(
'Missing ReST documentation; need at least e.g. "docs.rst"')
@checker('PWCK006', 'If C++, have <mod>/public/<mod>/*.h or '
'<mod>/public_override/*.h')
def check_has_public_or_override_headers(directory):
# TODO: Should likely have a decorator to check for C++ in a checker, or
# other more useful and cachable mechanisms.
if (not glob.glob(f'{directory}/**/*.cc', recursive=True)
and not glob.glob(f'{directory}/**/*.h', recursive=True)):
# No C++ files.
return
module_name = pathlib.Path(directory).name
has_public_cpp_headers = glob.glob(f'{directory}/public/{module_name}/*.h')
has_public_cpp_override_headers = glob.glob(
f'{directory}/public_overrides/**/*.h')
if not has_public_cpp_headers and not has_public_cpp_override_headers:
yield Issue(f'Have C++ code but no public/{module_name}/*.h '
'found and no public_overrides/ found')
multiple_public_directories = glob.glob(f'{directory}/public/*')
if len(multiple_public_directories) != 1:
yield Issue(f'Have multiple directories under public/; there should '
f'only be a single directory: "public/{module_name}". '
'Perhaps you were looking for public_overrides/?.')
def register_subcommand(parser: argparse.ArgumentParser) -> None:
"""Check that a module matches Pigweed's module guidelines."""
parser.add_argument('modules', nargs='+', help='The module to check')
parser.set_defaults(func=check_modules)