Repository: kytta/bumerge (language: python)

Issue:
Implement basic merging



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..22d966c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,13 @@
+<!--
+SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
+SPDX-License-Identifier: CC-BY-4.0 OR BSD-2-Clause
+-->
+
+# bumerge changelog
+
+## Unreleased
+
+### Added
+
+- [#4](https://github.com/kytta/bumerge/pull/4):
+  Basic configuration merging
diff --git a/bumerge.py b/bumerge.py
index 13b0c8a..550f98f 100644
--- a/bumerge.py
+++ b/bumerge.py
@@ -21,13 +21,63 @@
 """
 from __future__ import annotations
 
+from pathlib import Path
 from typing import TYPE_CHECKING
+from typing import Any
 
 if TYPE_CHECKING:
+    from collections.abc import Iterable
     from collections.abc import Sequence
 
+from ruamel.yaml import YAML
+
 __version__ = "0.1.0"
 
+# A very simple dict type for the config
+# TODO: use a proper schema (with validation?)
+JSONDict = dict[str, Any]
+
+
+def merge_dicts(source: JSONDict, destination: JSONDict) -> JSONDict:
+    """Merge one JSON-valid dictionary into another.
+
+    This goes over every key in `source` and sets the keys in
+    `destination` with the values, replacing on duplicates. If it
+    encounters another dict, it will merge it recursively.
+
+    :param source: the dictionary to be merged
+    :param destination: the dictionary to be merged into
+
+    :return: the result of merging two dicts
+    """
+    for key, value in source.items():
+        if isinstance(value, dict):
+            # get node or create one
+            node = destination.setdefault(key, {})
+            if not isinstance(node, dict):
+                destination[key] = value
+            else:
+                merge_dicts(value, node)
+        else:
+            destination[key] = value
+
+    return destination
+
+
+def read_config_files(config_files: Iterable[Path]) -> list[JSONDict]:
+    """Read config files and return the configurations.
+
+    :param config_files: list of configuration files to parse
+    :return: list of parsed configurations
+    """
+    yaml = YAML(typ="safe", pure=True)
+    result = []
+    for file in config_files:
+        with file.open() as fp:
+            result.append(yaml.load(fp))
+
+    return result
+
 
 def _main(argv: Sequence[str] | None = None) -> None:
     import argparse
@@ -39,7 +89,32 @@ def _main(argv: Sequence[str] | None = None) -> None:
         action="version",
         version=__version__,
     )
-    parser.parse_args(argv)
+    parser.add_argument(
+        "--output",
+        "-o",
+        type=argparse.FileType("w", encoding="UTF-8"),
+        default="-",
+        help="output file. Outputs to stdout by default",
+        metavar="FILE",
+    )
+    parser.add_argument(
+        "files",
+        nargs="+",
+        type=Path,
+        help="config files to merge",
+        metavar="FILE",
+    )
+    args = parser.parse_args(argv)
+
+    result: JSONDict = {}
+
+    for config in read_config_files(args.files):
+        merge_dicts(config, result)
+
+    yaml = YAML(typ="safe", pure=True)
+    yaml.default_flow_style = False
+
+    yaml.dump(result, args.output)
 
 
 if __name__ == "__main__":
diff --git a/pyproject.toml b/pyproject.toml
index 72f0442..ebc05ca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,7 @@ dynamic = [
   "version",
 ]
 dependencies = [
+  "ruamel.yaml>=0.15",
 ]
 [project.urls]
 "Bug tracker" = "https://github.com/kytta/bumerge/issues"
@@ -53,6 +54,7 @@ path = "bumerge.py"
 [tool.hatch.envs.default]
 dependencies = [
   "pytest",
+  "pytest-datadir",
   "coverage[toml]",
   "covdefaults",
 ]
@@ -155,6 +157,7 @@ unfixable = ["ERA001"]
     "PLR0913",  # too many function arguments
     "PLR2004",  # magic values
     "S101",  # use of assert detected
+    "SLF001",  # private method accessed
 ]
 
 [tool.ruff.flake8-annotations]


Test patch:
diff --git a/tests/bumerge_test.py b/tests/bumerge_test.py
index 09e60b2..46b691e 100644
--- a/tests/bumerge_test.py
+++ b/tests/bumerge_test.py
@@ -2,21 +2,175 @@
 # SPDX-License-Identifier: BSD-2-Clause
 from __future__ import annotations
 
+from typing import TYPE_CHECKING
+
 import pytest
 
+if TYPE_CHECKING:
+    from pathlib import Path
+
 import bumerge
 
 
 @pytest.mark.parametrize(
     ("argv", "output"), [
-        (None, ""),
+        ([], ""),
         (["--version"], bumerge.__version__),
         (["--help"], "usage:"),
     ],
 )
 def test_main(capsys, argv, output):
     with pytest.raises(SystemExit):
-        bumerge._main(argv)  # noqa: SLF001
+        bumerge._main(argv)
 
     captured = capsys.readouterr()
     assert output in captured.out
+
+
+def test_main_with_files(capsys, shared_datadir: Path):
+    files = [
+        shared_datadir / "root.bu",
+        shared_datadir / "users.bu",
+        shared_datadir / "disks.bu",
+        shared_datadir / "filesystems.bu",
+    ]
+    expected_output = """\
+passwd:
+  users:
+  - groups:
+    - sudo
+    - wheel
+    name: admin
+    ssh_authorized_keys:
+    - ssh-ed25519 ...
+storage:
+  disks:
+  - device: /dev/disk/by-id/coreos-boot-disk
+    partitions:
+    - label: root
+      number: 4
+      resize: true
+      size_mib: 8192
+    - label: var
+      size_mib: 0
+    wipe_table: false
+  filesystems:
+  - device: /dev/disk/by-partlabel/var
+    format: ext4
+    path: /var
+    with_mount_unit: true
+variant: fcos
+version: 1.5.0
+"""
+
+    bumerge._main([str(f) for f in files])
+
+    captured = capsys.readouterr()
+    assert expected_output in captured.out
+
+
+def test_main_writes_to_file(shared_datadir, tmp_path):
+    argv = [
+        "--output",
+        str(tmp_path / "output.bu"),
+        str((shared_datadir / "root.bu").resolve()),
+    ]
+    expected_output = """\
+variant: fcos
+version: 1.5.0
+"""
+
+    bumerge._main(argv)
+
+    assert (tmp_path / "output.bu").read_text() == expected_output
+
+
+@pytest.mark.parametrize(
+    ("source", "destination", "result"), [
+        ({}, {}, {}),
+        ({"key": 42}, {}, {"key": 42}),
+        ({"key": "new"}, {"key": "old"}, {"key": "new"}),
+        ({"key": {"sub": 42}}, {"key": "old"}, {"key": {"sub": 42}}),
+        (
+            {"key": {"sub": "new"}},
+            {"key": {"sub": "old"}},
+            {"key": {"sub": "new"}},
+        ),
+    ],
+)
+def test_merge_dicts(
+    source: bumerge.JSONDict,
+    destination: bumerge.JSONDict,
+    result: bumerge.JSONDict,
+):
+    retval = bumerge.merge_dicts(source, destination)
+
+    assert retval == result
+
+
+def test_read_config_files(shared_datadir: Path):
+    config_files = [
+        shared_datadir / "root.bu",
+        shared_datadir / "users.bu",
+        shared_datadir / "disks.bu",
+        shared_datadir / "filesystems.bu",
+    ]
+
+    retval = bumerge.read_config_files(config_files)
+
+    assert retval == [
+        {
+            "variant": "fcos",
+            "version": "1.5.0",
+        },
+        {
+            "passwd": {
+                "users": [
+                    {
+                        "name": "admin",
+                        "groups": [
+                            "sudo",
+                            "wheel",
+                        ],
+                        "ssh_authorized_keys": [
+                            "ssh-ed25519 ...",
+                        ],
+                    },
+                ],
+            },
+        },
+        {
+            "storage": {
+                "disks": [
+                    {
+                        "device": "/dev/disk/by-id/coreos-boot-disk",
+                        "wipe_table": False,
+                        "partitions": [
+                            {
+                                "label": "root",
+                                "number": 4,
+                                "size_mib": 8192,
+                                "resize": True,
+                            },
+                            {
+                                "label": "var",
+                                "size_mib": 0,
+                            },
+                        ],
+                    },
+                ],
+            },
+        },
+        {
+            "storage": {
+                "filesystems": [
+                    {
+                        "device": "/dev/disk/by-partlabel/var",
+                        "format": "ext4",
+                        "path": "/var",
+                        "with_mount_unit": True,
+                    },
+                ],
+            },
+        },
+    ]
diff --git a/tests/data/disks.bu b/tests/data/disks.bu
new file mode 100644
index 0000000..e6d31bc
--- /dev/null
+++ b/tests/data/disks.bu
@@ -0,0 +1,15 @@
+# SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
+# SPDX-License-Identifier: BSD-2-Clause
+
+storage:
+  disks:
+    - device: /dev/disk/by-id/coreos-boot-disk
+      wipe_table: false
+
+      partitions:
+        - label: root
+          number: 4
+          size_mib: 8192
+          resize: true
+        - label: var
+          size_mib: 0
diff --git a/tests/data/filesystems.bu b/tests/data/filesystems.bu
new file mode 100644
index 0000000..56fe2c3
--- /dev/null
+++ b/tests/data/filesystems.bu
@@ -0,0 +1,9 @@
+# SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
+# SPDX-License-Identifier: BSD-2-Clause
+
+storage:
+  filesystems:
+    - device: /dev/disk/by-partlabel/var
+      format: ext4
+      path: /var
+      with_mount_unit: true
diff --git a/tests/data/root.bu b/tests/data/root.bu
new file mode 100644
index 0000000..d909840
--- /dev/null
+++ b/tests/data/root.bu
@@ -0,0 +1,5 @@
+# SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
+# SPDX-License-Identifier: BSD-2-Clause
+
+variant: fcos
+version: 1.5.0
diff --git a/tests/data/users.bu b/tests/data/users.bu
new file mode 100644
index 0000000..cb55ee7
--- /dev/null
+++ b/tests/data/users.bu
@@ -0,0 +1,11 @@
+# SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
+# SPDX-License-Identifier: BSD-2-Clause
+
+passwd:
+  users:
+    - name: admin
+      groups:
+        - sudo
+        - wheel
+      ssh_authorized_keys:
+        - "ssh-ed25519 ..."