Repository: systemd/mkosi

Issue:
Add specifier support when parsing configuration files
I'm currently using a setup similar to the presets in the main systemd repository. I would like to output to a directory specified on the command line, as in my case the repository directory should not be written to.

The relative paths used for `BaseTrees` would not work then.  
Example:  
Preset base with `OutputDirectory=../../mkosi.output` (or no directory set)
Preset initrd with `BaseTrees=../../mkosi.output/base`

Call mkosi with `mkosi --output-directory=/tmp/mkosi.output`

A workaround for me here is calling the preset manually with the `--base-tree=/tmp/mkosi.output/base`, which gets unwieldy with several presets.

A possible solution I see is adding a PathsRelativeTo similar to what repart has, or calling `.format(output_directory=)` on these paths so one can use `BaseTrees={output}/base`


Discussion:
We should probably add specifier support. I'd also like to more easily allow specifying paths relative to the top level directory mkosi was invoked from. 

Resolving patch:
diff --git a/mkosi/config.py b/mkosi/config.py
--- a/mkosi/config.py
+++ b/mkosi/config.py
@@ -551,6 +551,7 @@ class MkosiConfigSetting:
     path_read_text: bool = False
     path_secret: bool = False
     path_default: bool = True
+    specifier: str = ""
 
     # settings for argparse
     short: Optional[str] = None
@@ -1054,6 +1055,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
         dest="distribution",
         short="-d",
         section="Distribution",
+        specifier="d",
         parse=config_make_enum_parser(Distribution),
         match=config_make_enum_matcher(Distribution),
         default_factory=config_default_distribution,
@@ -1064,6 +1066,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
         dest="release",
         short="-r",
         section="Distribution",
+        specifier="r",
         parse=config_parse_string,
         match=config_make_string_matcher(),
         default_factory=config_default_release,
@@ -1073,6 +1076,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
     MkosiConfigSetting(
         dest="architecture",
         section="Distribution",
+        specifier="a",
         parse=config_make_enum_parser(Architecture),
         match=config_make_enum_matcher(Architecture),
         default=Architecture.native(),
@@ -1123,6 +1127,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
         metavar="FORMAT",
         name="Format",
         section="Output",
+        specifier="t",
         parse=config_make_enum_parser(OutputFormat),
         match=config_make_enum_matcher(OutputFormat),
         default=OutputFormat.disk,
@@ -1141,6 +1146,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
         short="-o",
         metavar="NAME",
         section="Output",
+        specifier="o",
         parse=config_parse_filename,
         help="Output name",
     ),
@@ -1160,6 +1166,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
         metavar="DIR",
         name="OutputDirectory",
         section="Output",
+        specifier="O",
         parse=config_make_path_parser(required=False),
         paths=("mkosi.output",),
         default_factory=lambda _: Path.cwd(),
@@ -1865,6 +1872,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple
 )
 SETTINGS_LOOKUP_BY_NAME = {name: s for s in SETTINGS for name in [s.name, *s.compat_names]}
 SETTINGS_LOOKUP_BY_DEST = {s.dest: s for s in SETTINGS}
+SETTINGS_LOOKUP_BY_SPECIFIER = {s.specifier: s for s in SETTINGS if s.specifier}
 
 MATCHES = (
     MkosiMatch(
@@ -2068,6 +2076,37 @@ def parse_config(argv: Sequence[str] = ()) -> tuple[MkosiArgs, tuple[MkosiConfig
     # Compare inodes instead of paths so we can't get tricked by bind mounts and such.
     parsed_includes: set[tuple[int, int]] = set()
 
+    def expand_specifiers(text: str, namespace: argparse.Namespace, defaults: argparse.Namespace) -> str:
+        percent = False
+        result: list[str] = []
+
+        for c in text:
+            if percent:
+                percent = False
+
+                if c == "%":
+                    result += "%"
+                else:
+                    s = SETTINGS_LOOKUP_BY_SPECIFIER.get(c)
+                    if not s:
+                        logging.warning(f"Unknown specifier '%{c}' found in {text}, ignoring")
+                        continue
+
+                    if (v := finalize_default(s, namespace, defaults)) is None:
+                        logging.warning(f"Setting {s.name} specified by specifier '%{c}' in {text} is not yet set, ignoring")
+                        continue
+
+                    result += str(v)
+            elif c == "%":
+                percent = True
+            else:
+                result += c
+
+        if percent:
+            result += "%"
+
+        return "".join(result)
+
     @contextlib.contextmanager
     def parse_new_includes(
         namespace: argparse.Namespace,
@@ -2187,6 +2226,7 @@ def match_config(path: Path, namespace: argparse.Namespace, defaults: argparse.N
         return triggered is not False
 
     def parse_config(path: Path, namespace: argparse.Namespace, defaults: argparse.Namespace) -> bool:
+        s: Optional[MkosiConfigSetting] # Make mypy happy
         extras = path.is_dir()
 
         if path.is_dir():
@@ -2195,6 +2235,22 @@ def parse_config(path: Path, namespace: argparse.Namespace, defaults: argparse.N
         if not match_config(path, namespace, defaults):
             return False
 
+        if extras:
+            for s in SETTINGS:
+                ns = defaults if s.path_default else namespace
+                for f in s.paths:
+                    p = parse_path(
+                        f,
+                        secret=s.path_secret,
+                        required=False,
+                        resolve=False,
+                        expanduser=False,
+                        expandvars=False,
+                    )
+                    if p.exists():
+                        setattr(ns, s.dest,
+                                s.parse(p.read_text() if s.path_read_text else f, getattr(ns, s.dest, None)))
+
         if path.exists():
             logging.debug(f"Including configuration file {Path.cwd() / path}")
 
@@ -2212,30 +2268,16 @@ def parse_config(path: Path, namespace: argparse.Namespace, defaults: argparse.N
                     canonical = s.name if k == name else f"@{s.name}"
                     logging.warning(f"Setting {k} is deprecated, please use {canonical} instead.")
 
+                v = expand_specifiers(v, namespace, defaults)
+
                 with parse_new_includes(namespace, defaults):
                     setattr(ns, s.dest, s.parse(v, getattr(ns, s.dest, None)))
 
-        if extras:
-            for s in SETTINGS:
-                ns = defaults if s.path_default else namespace
-                for f in s.paths:
-                    p = parse_path(
-                        f,
-                        secret=s.path_secret,
-                        required=False,
-                        resolve=False,
-                        expanduser=False,
-                        expandvars=False,
-                    )
-                    if p.exists():
-                        setattr(ns, s.dest,
-                                s.parse(p.read_text() if s.path_read_text else f, getattr(ns, s.dest, None)))
-
-            if (path.parent / "mkosi.conf.d").exists():
-                for p in sorted((path.parent / "mkosi.conf.d").iterdir()):
-                    if p.is_dir() or p.suffix == ".conf":
-                        with chdir(p if p.is_dir() else Path.cwd()):
-                            parse_config(p if p.is_file() else Path("."), namespace, defaults)
+        if extras and (path.parent / "mkosi.conf.d").exists():
+            for p in sorted((path.parent / "mkosi.conf.d").iterdir()):
+                if p.is_dir() or p.suffix == ".conf":
+                    with chdir(p if p.is_dir() else Path.cwd()):
+                        parse_config(p if p.is_file() else Path("."), namespace, defaults)
 
         return True
 


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -598,3 +598,32 @@ def test_config_parse_bytes() -> None:
         config_parse_bytes("-3M")
     with pytest.raises(SystemExit):
         config_parse_bytes("-4G")
+
+
+def test_specifiers(tmp_path: Path) -> None:
+    d = tmp_path
+
+    (d / "mkosi.conf").write_text(
+        """\
+        [Distribution]
+        Distribution=ubuntu
+        Release=lunar
+        Architecture=arm64
+
+        [Output]
+        OutputDirectory=abcde
+        Output=test
+
+        [Distribution]
+        ImageId=%d~%r~%a
+
+        [Output]
+        CacheDirectory=%O/%o
+        """
+    )
+
+    with chdir(d):
+        _, [config] = parse_config()
+        assert config.distribution == Distribution.ubuntu
+        assert config.image_id == "ubuntu~lunar~arm64"
+        assert config.cache_dir == Path.cwd() / "abcde/test" / config.image_id