From 1205ca31b8624a9f25f44dba55f9f98105bd977e Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Sat, 30 Sep 2023 16:43:05 +0200 Subject: [PATCH] Add Include= setting This setting allows including extra configuration from user specified directories or files. The extra configuration is parsed immediately. --- mkosi/config.py | 102 ++++++++++++++++++++++++--------------- mkosi/resources/mkosi.md | 10 ++++ tests/test_config.py | 42 +++++++++++++++- 3 files changed, 112 insertions(+), 42 deletions(-) diff --git a/mkosi/config.py b/mkosi/config.py index 915c4688f..ddf4d2b5f 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -2,6 +2,7 @@ import argparse import base64 +import contextlib import copy import dataclasses import enum @@ -20,7 +21,7 @@ import textwrap import uuid from collections.abc import Collection, Iterable, Iterator, Sequence from pathlib import Path -from typing import Any, Callable, Collection, Optional, Union, cast +from typing import Any, Callable, Optional, Type, Union, cast from mkosi.architecture import Architecture from mkosi.distributions import Distribution, detect_distribution @@ -365,7 +366,8 @@ def config_make_enum_matcher(type: type[enum.Enum]) -> ConfigMatchCallback: def config_make_list_parser(delimiter: str, *, parse: Callable[[str], Any] = str, - unescape: bool = False) -> ConfigParseCallback: + unescape: bool = False, + reset: bool = True) -> ConfigParseCallback: def config_parse_list(value: Optional[str], old: Optional[list[Any]]) -> Optional[list[Any]]: new = old.copy() if old else [] @@ -382,7 +384,7 @@ def config_make_list_parser(delimiter: str, values = value.replace(delimiter, "\n").split("\n") # Empty strings reset the list. - if len(values) == 1 and values[0] == "": + if reset and len(values) == 1 and values[0] == "": return None return new + [parse(v) for v in values if v] @@ -598,37 +600,6 @@ class IgnoreAction(argparse.Action): logging.warning(f"{option_string} is no longer supported") -def config_make_action(settings: Sequence[MkosiConfigSetting]) -> type[argparse.Action]: - lookup = {s.dest: s for s in settings} - - class MkosiAction(argparse.Action): - def __call__( - self, - parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - values: Union[str, Sequence[Any], None], - option_string: Optional[str] = None - ) -> None: - assert option_string is not None - - if values is None and self.nargs == "?": - values = self.const or "yes" - - try: - s = lookup[self.dest] - except KeyError: - die(f"Unknown setting {option_string}") - - if values is None or isinstance(values, str): - setattr(namespace, s.dest, s.parse(values, getattr(namespace, self.dest, None))) - else: - for v in values: - assert isinstance(v, str) - setattr(namespace, s.dest, s.parse(v, getattr(namespace, self.dest, None))) - - return MkosiAction - - class PagerHelpAction(argparse._HelpAction): def __call__( self, @@ -672,6 +643,7 @@ class MkosiConfig: access the value from state. """ + include: tuple[str, ...] presets: tuple[str] dependencies: tuple[str] @@ -934,6 +906,12 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple SETTINGS = ( + MkosiConfigSetting( + dest="include", + section="Config", + parse=config_make_list_parser(delimiter=",", reset=False, parse=make_path_parser()), + help="Include configuration from the specified file or directory", + ), MkosiConfigSetting( dest="presets", long="--preset", @@ -1767,9 +1745,7 @@ MATCHES = ( ) -def create_argument_parser() -> argparse.ArgumentParser: - action = config_make_action(SETTINGS) - +def create_argument_parser(action: Type[argparse.Action]) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="mkosi", description="Build Bespoke OS Images", @@ -1958,6 +1934,46 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig settings_lookup_by_dest = {s.dest: s for s in SETTINGS} match_lookup = {m.name: m for m in MATCHES} + @contextlib.contextmanager + def parse_new_includes( + namespace: argparse.Namespace, + defaults: argparse.Namespace, + ) -> Iterator[None]: + l = len(getattr(namespace, "include", [])) + + try: + yield + finally: + # Parse any includes that were added after yielding. + for p in getattr(namespace, "include", [])[l:]: + parse_config(p, namespace, defaults) + + class MkosiAction(argparse.Action): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Union[str, Sequence[Any], None], + option_string: Optional[str] = None + ) -> None: + assert option_string is not None + + if values is None and self.nargs == "?": + values = self.const or "yes" + + try: + s = settings_lookup_by_dest[self.dest] + except KeyError: + die(f"Unknown setting {option_string}") + + with parse_new_includes(namespace, defaults): + if values is None or isinstance(values, str): + setattr(namespace, s.dest, s.parse(values, getattr(namespace, self.dest, None))) + else: + for v in values: + assert isinstance(v, str) + setattr(namespace, s.dest, s.parse(v, getattr(namespace, self.dest, None))) + def finalize_default( setting: MkosiConfigSetting, namespace: argparse.Namespace, @@ -1978,7 +1994,9 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig else: default = setting.default - setattr(namespace, setting.dest, default) + with parse_new_includes(namespace, defaults): + setattr(namespace, setting.dest, default) + return default def match_config(path: Path, namespace: argparse.Namespace, defaults: argparse.Namespace) -> bool: @@ -2053,7 +2071,8 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig canonical = s.name if k == name else f"@{s.name}" logging.warning(f"Setting {k} is deprecated, please use {canonical} instead.") - setattr(ns, s.dest, s.parse(v, getattr(ns, s.dest, None))) + with parse_new_includes(namespace, defaults): + setattr(ns, s.dest, s.parse(v, getattr(ns, s.dest, None))) if extras: for s in SETTINGS: @@ -2105,7 +2124,7 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig argv += ["--", "build"] namespace = argparse.Namespace() - argparser = create_argument_parser() + argparser = create_argument_parser(MkosiAction) argparser.parse_args(argv, namespace) args = load_args(namespace) @@ -2396,6 +2415,9 @@ def summary(args: MkosiArgs, config: MkosiConfig) -> str: Verb: {bold(args.verb)} Cmdline: {bold(" ".join(args.cmdline))} + {bold("CONFIG")}: + Include: {line_join_list(config.include)} + {bold("PRESET")}: Presets: {line_join_list(config.presets)} Dependencies: {line_join_list(config.dependencies)} diff --git a/mkosi/resources/mkosi.md b/mkosi/resources/mkosi.md index 95231d83e..c7ce31451 100644 --- a/mkosi/resources/mkosi.md +++ b/mkosi/resources/mkosi.md @@ -356,6 +356,16 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, | `Format=` | no | no | match default format | | `SystemdVersion=` | no | yes | match fails | +### [Config] Section + +`Include=`, `--include=` + +: Include extra configuration from the given file or directory. The + extra configuration is included immediately after parsing the setting, + except when a default is set using `@Include=`, in which case the + configuration is included after parsing all the other configuration + files. + ### [Preset] Section `Presets=`, `--preset=` diff --git a/tests/test_config.py b/tests/test_config.py index d2ce5de6f..f2e1519cb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,13 +4,21 @@ import argparse import itertools import logging import operator +import os from pathlib import Path from typing import Optional import pytest from mkosi.architecture import Architecture -from mkosi.config import Compression, OutputFormat, Verb, parse_config, parse_ini +from mkosi.config import ( + Compression, + ConfigFeature, + OutputFormat, + Verb, + parse_config, + parse_ini, +) from mkosi.distributions import Distribution from mkosi.util import chdir @@ -172,6 +180,36 @@ def test_parse_config(tmp_path: Path) -> None: # ImageVersion= is not set explicitly anymore, so now the version from mkosi.version should be used. assert config.image_version == "1.2.3" + (tmp_path / "abc").mkdir() + (tmp_path / "abc/mkosi.conf").write_text( + """\ + [Content] + Bootable=yes + """ + ) + (tmp_path / "abc/mkosi.conf.d").mkdir() + (tmp_path / "abc/mkosi.conf.d/abc.conf").write_text( + """\ + [Output] + SplitArtifacts=yes + """ + ) + + with chdir(tmp_path): + _, [config] = parse_config() + assert config.bootable == ConfigFeature.auto + assert config.split_artifacts == False + + # Passing the directory should include both the main config file and the dropin. + _, [config] = parse_config(["--include", os.fspath(tmp_path / "abc")]) + assert config.bootable == ConfigFeature.enabled + assert config.split_artifacts == True + + # Passing the main config file should not include the dropin. + _, [config] = parse_config(["--include", os.fspath(tmp_path / "abc/mkosi.conf")]) + assert config.bootable == ConfigFeature.enabled + assert config.split_artifacts == False + def test_parse_load_verb(tmp_path: Path) -> None: (tmp_path / "mkosi.conf").write_text("[Distribution]\nDistribution=fedora") @@ -558,7 +596,7 @@ def test_wrong_section_warning( f"""\ [Distribution] Distribution=fedora - + [{section}] ImageId=testimage """ -- 2.47.2