Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add CI pipeline #13

Merged
merged 17 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
name: Run tests

on:
push:
branches: ['main']
pull_request:

jobs:
pytest:
uses: colcon/ci/.github/workflows/pytest.yaml@main
123 changes: 103 additions & 20 deletions colcon_meson/build.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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())
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)}

Expand All @@ -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)
Expand All @@ -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"]

Expand All @@ -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")
Expand All @@ -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"]

Expand All @@ -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()
Expand Down
73 changes: 49 additions & 24 deletions colcon_meson/identification.py
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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'])
Loading