diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f3cef12 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +--- +name: Run tests + +on: + push: + branches: ['main'] + pull_request: + +jobs: + pytest: + uses: colcon/ci/.github/workflows/pytest.yaml@main diff --git a/colcon_meson/build.py b/colcon_meson/build.py index 1ac1650..0a0e569 100644 --- a/colcon_meson/build.py +++ b/colcon_meson/build.py @@ -1,21 +1,35 @@ +# Copyright 2024 Christian Rauch +# Licensed under the Apache License, Version 2.0 + +from argparse import ArgumentParser, Namespace +import json import os from pathlib import Path import shutil -import json - -from mesonbuild import coredata -from mesonbuild.mesonmain import CommandLineParser +# colcon from colcon_core.environment import create_environment_scripts from colcon_core.logging import colcon_logger from colcon_core.shell import get_command_environment from colcon_core.task import run from colcon_core.task import TaskExtensionPoint +# meson +from mesonbuild import coredata +from mesonbuild.mesonmain import CommandLineParser logger = colcon_logger.getChild(__name__) def cfg_changed(old, new): + """Compare two configurations and return true if they are equal. + + Args: + old (dict): old configuration + new (dict): new configuration + + Returns: + bool: true if configurations are equal and false otherwise + """ for p in old.keys() & new.keys(): n = new[p] # convert string representations of boolen values @@ -28,6 +42,16 @@ def cfg_changed(old, new): def cfg_diff(old, new): + """Compare two configurations and return the change. + + Args: + old (dict): old configuration + new (dict): new configuration + + Returns: + (dict, dict): tuple with key-value pairs that were added and remove + between the old and new configuration + """ # get changes between old and new configuration k_removed = set(old.keys()) - set(new.keys()) k_added = set(new.keys()) - set(old.keys()) @@ -37,23 +61,49 @@ def cfg_diff(old, new): def format_args(args): + """Convert Meson command line arguments into key-value pairs. + + Args: + args: Meson command line arguments + + Returns: + dict: converted arguments as key-value pairs + """ return {arg.name: args.cmd_line_options[arg] for arg in args.cmd_line_options} class MesonBuildTask(TaskExtensionPoint): + """Task to build a Meson project.""" + def __init__(self): + """Initialise the build task by discovering meson and setting up the parser.""" super().__init__() self.meson_path = shutil.which("meson") self.parser_setup = CommandLineParser().subparsers.choices["setup"] - def add_arguments(self, *, parser): + def add_arguments(self, *, parser: ArgumentParser): + """Add new arguments to the colcon build argument parser. + + Args: + parser (ArgumentParser): argument parser + """ parser.add_argument('--meson-args', - nargs='*', metavar='*', type=str.lstrip, default=list(), - help="Pass 'setup' arguments to Meson projects.") + nargs='*', metavar='*', + type=str.lstrip, default=[], + help="Pass 'setup' arguments to Meson projects.", + ) + + def get_default_args(self, args: Namespace) -> list[str]: + """Get default Meson arguments. + + Args: + args (Namespace): parse arguments from an ArgumentParser - def get_default_args(self, args): - margs = list() + Returns: + list: list of command line arguments for meson + """ + margs = [] # meson installs by default to architecture specific subdirectories, # e.g. "lib/x86_64-linux-gnu", but the LibraryPathEnvironment hook @@ -71,21 +121,50 @@ def get_default_args(self, args): return margs - def meson_parse_cmdline(self, cmdline): + def meson_parse_cmdline(self, cmdline: list[str]) -> Namespace: + """Parse command line arguments with the Meson arg parser. + + Args: + cmdline (list): command line arguments + + Returns: + Namespace: parse args + """ args = self.parser_setup.parse_args(cmdline) coredata.parse_cmd_line_options(args) return args - def meson_format_cmdline(self, cmdline): + def meson_format_cmdline(self, cmdline: list[str]): + """Convert Meson args from command line. + + Args: + cmdline (list): command line arguments + + Returns: + dict: converted key-value pairs + """ return format_args(self.meson_parse_cmdline(cmdline)) - def meson_format_cmdline_file(self, builddir): + def meson_format_cmdline_file(self, builddir: str): + """Convert Meson args from command line arguments stored in the build directory. + + Args: + builddir (str): path to the build directory + + Returns: + dict: converted key-value pairs + """ args = self.meson_parse_cmdline([]) coredata.read_cmd_line_file(builddir, args) return format_args(args) async def build(self, *, additional_hooks=None, skip_hook_creation=False, environment_callback=None, additional_targets=None): + """Full build pipeline for a Meson project. + + Returns: + int: return code + """ args = self.context.args try: @@ -144,7 +223,7 @@ async def _reconfigure(self, args, env): newcfg[arg] = defcfg[arg] # parse old configuration from meson cache - assert(configfile.exists()) + assert configfile.exists() with open(configfile, 'r') as f: mesoncfg = {arg["name"]: arg["value"] for arg in json.load(f)} @@ -154,7 +233,7 @@ async def _reconfigure(self, args, env): if not run_init_setup and not config_changed: return - cmd = list() + cmd = [] cmd += [self.meson_path] cmd += ["setup"] cmd.extend(marg_def) @@ -172,7 +251,7 @@ async def _reconfigure(self, args, env): async def _build(self, args, env, *, additional_targets=None): self.progress('build') - cmd = list() + cmd = [] cmd += [self.meson_path] cmd += ["compile"] @@ -193,9 +272,9 @@ async def _install(self, args, env): lastinstalltargetfile = Path(args.build_base) / "last_install_targets.json" # get current install targets - assert(mesontargetfile.exists()) + assert mesontargetfile.exists() with open(mesontargetfile, 'r') as f: - install_targets = {target["name"]:target["install_filename"] for target in json.load(f) if target["installed"]} + install_targets = {target["name"]: target["install_filename"] for target in json.load(f) if target["installed"]} if not install_targets: logger.error("no install targets") @@ -220,7 +299,7 @@ async def _install(self, args, env): with open(lastinstalltargetfile, 'w') as f: json.dump(install_targets, f) - cmd = list() + cmd = [] cmd += [self.meson_path] cmd += ["install"] @@ -231,10 +310,14 @@ async def _install(self, args, env): class RosMesonBuildTask(TaskExtensionPoint): - def __init__(self): - super().__init__() + """Task to build a Meson project.""" async def build(self): + """Full build pipeline for a Meson project with a package.xml. + + Returns: + int: return code + """ meson_extension = MesonBuildTask() meson_extension.set_context(context=self.context) rc = await meson_extension.build() diff --git a/colcon_meson/identification.py b/colcon_meson/identification.py index 2c880d1..11a75fc 100644 --- a/colcon_meson/identification.py +++ b/colcon_meson/identification.py @@ -1,20 +1,28 @@ +# Copyright 2024 Christian Rauch +# Licensed under the Apache License, Version 2.0 + import os import typing +# colcon +from colcon_core.logging import colcon_logger +from colcon_core.package_descriptor import PackageDescriptor +from colcon_core.package_identification import PackageIdentificationExtensionPoint +# meson from mesonbuild import environment from mesonbuild import mesonlib -from mesonbuild.interpreterbase.interpreterbase import InterpreterBase -from mesonbuild.interpreterbase.baseobjects import * from mesonbuild.interpreter import primitives - -from colcon_core.logging import colcon_logger -from colcon_core.package_identification import PackageIdentificationExtensionPoint +from mesonbuild.interpreterbase.baseobjects import InterpreterObject, mparser +from mesonbuild.interpreterbase.interpreterbase import InterpreterBase logger = colcon_logger.getChild(__name__) class CustomInterpreter(InterpreterBase): + """A custom interpreter to parse metadata for Meson projects.""" + def __init__(self, source_root: str, subdir: str, subproject: str): + """Initialise the interpreter and a data structure for metadata.""" super().__init__(source_root, subdir, subproject) self.holder_map.update({ @@ -27,21 +35,29 @@ def __init__(self, source_root: str, subdir: str, subproject: str): self.environment = environment - self.data = dict() + self.data = {} self.data["dependencies"] = set() def evaluate_statement(self, cur: mparser.BaseNode) -> typing.Optional[InterpreterObject]: + """Evaluate the statements in the Meson project file. + + Args: + cur (mparser.BaseNode): a node in the project file + + Returns: + typing.Optional[InterpreterObject]: + """ if isinstance(cur, mparser.FunctionNode): - return self.function_call(cur) + return self._function_call(cur) elif isinstance(cur, mparser.AssignmentNode): - self.assignment(cur) + self._assignment(cur) elif isinstance(cur, mparser.StringNode): return self._holderify(cur.value) elif isinstance(cur, mparser.ArrayNode): - return self.evaluate_arraystatement(cur) + return self._evaluate_arraystatement(cur) return None - def function_call(self, node: mparser.FunctionNode) -> typing.Optional[InterpreterObject]: + def _function_call(self, node: mparser.FunctionNode) -> typing.Optional[InterpreterObject]: node_func_name = f"{type(node.func_name).__module__}.{type(node.func_name).__qualname__}" if node_func_name == "str": # meson <= 1.2 @@ -52,7 +68,7 @@ def function_call(self, node: mparser.FunctionNode) -> typing.Optional[Interpret else: raise AttributeError("Cannot determine meson project name.") - assert type(func_name) == str + assert type(func_name) is str reduced_pos = [self.evaluate_statement(arg) for arg in node.args.arguments] reduced_pos = list(filter(None, reduced_pos)) @@ -70,42 +86,51 @@ def function_call(self, node: mparser.FunctionNode) -> typing.Optional[Interpret self.data[k].update(subdata[k]) return None - def assignment(self, node: mparser.AssignmentNode) -> None: + def _assignment(self, node: mparser.AssignmentNode) -> None: self.evaluate_statement(node.value) return None - def evaluate_arraystatement(self, cur: mparser.ArrayNode) -> InterpreterObject: + def _evaluate_arraystatement(self, cur: mparser.ArrayNode) -> InterpreterObject: arguments = [self.evaluate_statement(arg) for arg in cur.args.arguments] arguments = list(filter(None, arguments)) return self._holderify(self._unholder_args(arguments, {})[0]) def parse(self) -> dict: + """Run the interpreter on a Meson project file. + + Returns: + dict: extracted metadata + """ try: self.load_root_meson_file() except mesonlib.MesonException: - return dict() + return {} self.evaluate_codeblock(self.ast) return self.data class MesonPackageIdentification(PackageIdentificationExtensionPoint): - def __init__(self): - super().__init__() + """Meson package identification.""" + + def identify(self, desc: PackageDescriptor): + """Identify a Meson project for colcon. - def identify(self, metadata): - parser = CustomInterpreter(metadata.path, "", "") + Args: + desc (PackageDescriptor): package description that will be updated + """ + parser = CustomInterpreter(desc.path, "", "") data = parser.parse() if not data: return - metadata.type = 'meson' + desc.type = 'meson' - if metadata.name is None: - metadata.name = data["name"] + if desc.name is None: + desc.name = data["name"] - logger.info("'%s' dependencies: %s", metadata.name, data['dependencies']) + logger.info("'%s' dependencies: %s", desc.name, data['dependencies']) - metadata.dependencies['build'].update(data['dependencies']) - metadata.dependencies['run'].update(data['dependencies']) + desc.dependencies['build'].update(data['dependencies']) + desc.dependencies['run'].update(data['dependencies']) diff --git a/setup.cfg b/setup.cfg index 6020385..da4865e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,9 +21,38 @@ install_requires = meson >= 0.60.0 packages = find: +[options.extras_require] +test = + flake8>=3.6.0,<6 + flake8-blind-except + flake8-builtins + flake8-class-newline + flake8-comprehensions + flake8-deprecated + flake8-docstrings + flake8-import-order + flake8-quotes + pep8-naming + pylint + pytest + pytest-cov + scspell3k>=2.2 + +[tool:pytest] +junit_suite_name = colcon-meson +markers = + flake8 + linter + [options.entry_points] colcon_core.package_identification = meson = colcon_meson.identification:MesonPackageIdentification colcon_core.task.build = meson = colcon_meson.build:MesonBuildTask ros.meson = colcon_meson.build:RosMesonBuildTask + +[flake8] +import-order-style = google + +[coverage:run] +source = colcon_meson diff --git a/setup.py b/setup.py index b908cbe..affb702 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,6 @@ +# Copyright 2024 Christian Rauch +# Licensed under the Apache License, Version 2.0 + import setuptools setuptools.setup() diff --git a/test/spell_check.words b/test/spell_check.words new file mode 100644 index 0000000..05693b8 --- /dev/null +++ b/test/spell_check.words @@ -0,0 +1,51 @@ +apache +argparse +arraystatement +baseobjects +boolen +builddir +buildfile +buildoptions +buildtype +cmdline +codeblock +colcon +configfile +coredata +defcfg +holderify +initialise +interpreterbase +iterdir +lastinstalltargetfile +libdir +linter +linux +lstrip +makeflags +margs +mesonbuild +mesoncfg +mesonlib +mesonmain +mesontargetfile +mparser +nargs +newcfg +oldcfg +pathlib +pydocstyle +pytest +qualname +rauch +returncode +rmtree +scspell +setuptools +sourcedir +subdata +subparsers +subpath +subproject +thomas +unholder diff --git a/test/test_copyright_license.py b/test/test_copyright_license.py new file mode 100644 index 0000000..dffe460 --- /dev/null +++ b/test/test_copyright_license.py @@ -0,0 +1,42 @@ +# Copyright 2016-2018 Dirk Thomas +# Licensed under the Apache License, Version 2.0 + +from pathlib import Path +import sys + +import pytest + + +@pytest.mark.linter +def test_copyright_license(): + missing = check_files([Path(__file__).parents[1]]) + assert not len(missing), \ + 'In some files no copyright / license line was found' + + +def check_files(paths): + missing = [] + for path in paths: + if path.is_dir(): + for p in sorted(path.iterdir()): + if p.name.startswith('.'): + continue + if p.name.endswith('.py') or p.is_dir(): + missing += check_files([p]) + if path.is_file(): + content = path.read_text() + if not content: + continue + lines = content.splitlines() + has_copyright = any(filter( + lambda line: line.startswith('# Copyright'), lines)) + has_license = \ + '# Licensed under the Apache License, Version 2.0' in lines + if not has_copyright or not has_license: + print( + 'Could not find copyright / license in:', path, + file=sys.stderr) + missing .append(path) + else: + print('Found copyright / license in:', path) + return missing diff --git a/test/test_flake8.py b/test/test_flake8.py new file mode 100644 index 0000000..5af61f4 --- /dev/null +++ b/test/test_flake8.py @@ -0,0 +1,59 @@ +# Copyright 2016-2018 Dirk Thomas +# Licensed under the Apache License, Version 2.0 + +import logging +from pathlib import Path +import sys + +import pytest + + +@pytest.mark.flake8 +@pytest.mark.linter +def test_flake8(): + from flake8.api.legacy import get_style_guide + + # avoid debug / info / warning messages from flake8 internals + logging.getLogger('flake8').setLevel(logging.ERROR) + + # for some reason the pydocstyle logger changes to an effective level of 1 + # set higher level to prevent the output to be flooded with debug messages + logging.getLogger('pydocstyle').setLevel(logging.WARNING) + + style_guide = get_style_guide( + extend_ignore=[ + 'D100', + 'D104', + 'E501', # line too long + 'Q000', # Double quotes found but single quotes preferred + ], + show_source=True, + ) + style_guide_tests = get_style_guide( + extend_ignore=['D100', 'D101', 'D102', 'D103', 'D104', 'D105', 'D107'], + show_source=True, + ) + + stdout = sys.stdout + sys.stdout = sys.stderr + # implicitly calls report_errors() + report = style_guide.check_files([ + str(Path(__file__).parents[1] / 'colcon_meson'), + ]) + report_tests = style_guide_tests.check_files([ + str(Path(__file__).parents[1] / 'test'), + ]) + sys.stdout = stdout + + total_errors = report.total_errors + report_tests.total_errors + if total_errors: # pragma: no cover + # output summary with per-category counts + print() + if report.total_errors: + report._application.formatter.show_statistics(report._stats) + if report_tests.total_errors: + report_tests._application.formatter.show_statistics( + report_tests._stats) + print(f'flake8 reported {total_errors} errors', file=sys.stderr) + + assert not total_errors, f'flake8 reported {total_errors} errors' diff --git a/test/test_spell_check.py b/test/test_spell_check.py new file mode 100644 index 0000000..078fd63 --- /dev/null +++ b/test/test_spell_check.py @@ -0,0 +1,59 @@ +# Copyright 2016-2019 Dirk Thomas +# Licensed under the Apache License, Version 2.0 + +from pathlib import Path + +import pytest + +spell_check_words_path = Path(__file__).parent / 'spell_check.words' + + +@pytest.fixture(scope='module') +def known_words(): + global spell_check_words_path + return spell_check_words_path.read_text().splitlines() + + +@pytest.mark.linter +def test_spell_check(known_words): + from scspell import Report + from scspell import SCSPELL_BUILTIN_DICT + from scspell import spell_check + + source_filenames = [Path(__file__).parents[1] / 'setup.py'] + \ + list( + (Path(__file__).parents[1] / 'colcon_meson') + .glob('**/*.py')) + \ + list((Path(__file__).parents[1] / 'test').glob('**/*.py')) + + for source_filename in sorted(source_filenames): + print('Spell checking:', source_filename) + + # check all files + report = Report(known_words) + spell_check( + [str(p) for p in source_filenames], base_dicts=[SCSPELL_BUILTIN_DICT], + report_only=report, additional_extensions=[('', 'Python')]) + + unknown_word_count = len(report.unknown_words) + assert unknown_word_count == 0, \ + f'Found {unknown_word_count} unknown words: ' + \ + ', '.join(sorted(report.unknown_words)) + + unused_known_words = set(known_words) - report.found_known_words + unused_known_word_count = len(unused_known_words) + assert unused_known_word_count == 0, \ + f'{unused_known_word_count} words in the word list are not used: ' + \ + ', '.join(sorted(unused_known_words)) + + +@pytest.mark.linter +def test_spell_check_word_list_order(known_words): + assert known_words == sorted(known_words), \ + 'The word list should be ordered alphabetically' + + +@pytest.mark.linter +def test_spell_check_word_list_duplicates(known_words): + assert len(known_words) == len(set(known_words)), \ + 'The word list should not contain duplicates'