Michael Hudson-Doyle has proposed merging 
~mwhudson/curtin:more-grub-config-object into curtin:master.

Commit message:
extend grub config object to handle all config under the 'grub' key

Pinched a hacked down version of my deserialization code from
subiquity...

I'm not sure this 100% preserves the behaviour of install_devices being
an empty list, None or absent entirely, the semantics around all that
seem to be pretty confused.



Requested reviews:
  curtin developers (curtin-dev)

For more details, see:
https://code.launchpad.net/~mwhudson/curtin/+git/curtin/+merge/444608
-- 
Your team curtin developers is requested to review the proposed merge of 
~mwhudson/curtin:more-grub-config-object into curtin:master.
diff --git a/curtin/commands/curthooks.py b/curtin/commands/curthooks.py
index 868fbad..759ed5e 100644
--- a/curtin/commands/curthooks.py
+++ b/curtin/commands/curthooks.py
@@ -423,9 +423,8 @@ def install_kernel(cfg, target):
                      " System may not boot.", package)
 
 
-def uefi_remove_old_loaders(grubcfg: dict, target):
+def uefi_remove_old_loaders(grubcfg: config.GrubConfig, target):
     """Removes the old UEFI loaders from efibootmgr."""
-    grubcfg = config.fromdict(config.GrubConfig, grubcfg)
     efi_state = util.get_efibootmgr(target)
 
     LOG.debug('UEFI remove old olders efi state:\n%s', efi_state)
@@ -517,7 +516,7 @@ def _reorder_new_entry(
 
 
 def uefi_reorder_loaders(
-        grubcfg: dict,
+        grubcfg: config.GrubConfig,
         target: str,
         efi_orig_state: util.EFIBootState,
         variant: str,
@@ -533,8 +532,6 @@ def uefi_reorder_loaders(
     is installed after the the previous first entry (before we installed grub).
 
     """
-    grubcfg = config.fromdict(config.GrubConfig, grubcfg)
-
     if not grubcfg.reorder_uefi:
         LOG.debug("Skipped reordering of UEFI boot methods.")
         LOG.debug("Currently booted UEFI loader might no longer boot.")
@@ -578,9 +575,10 @@ def uefi_reorder_loaders(
             in_chroot.subp(['efibootmgr', '-o', new_boot_order])
 
 
-def uefi_remove_duplicate_entries(grubcfg: dict, target: str) -> None:
-    grubcfg = config.fromdict(config.GrubConfig, grubcfg)
-
+def uefi_remove_duplicate_entries(
+        grubcfg: config.GrubConfig,
+        target: str,
+        ) -> None:
     if not grubcfg.remove_duplicate_entries:
         LOG.debug("Skipped removing duplicate UEFI boot entries per config.")
         return
@@ -725,7 +723,12 @@ def uefi_find_grub_device_ids(sconfig):
     return grub_device_ids
 
 
-def setup_grub(cfg, target, osfamily, variant):
+def setup_grub(
+        cfg: dict,
+        target: str,
+        osfamily: str,
+        variant: str,
+        ) -> None:
     # target is the path to the mounted filesystem
 
     # FIXME: these methods need moving to curtin.block
@@ -733,11 +736,13 @@ def setup_grub(cfg, target, osfamily, variant):
     from curtin.commands.block_meta import (extract_storage_ordered_dict,
                                             get_path_to_storage_volume)
 
-    grubcfg = cfg.get('grub', {})
+    grubcfg_d = cfg.get('grub', {})
 
     # copy legacy top level name
-    if 'grub_install_devices' in cfg and 'install_devices' not in grubcfg:
-        grubcfg['install_devices'] = cfg['grub_install_devices']
+    if 'grub_install_devices' in cfg and 'install_devices' not in grubcfg_d:
+        grubcfg_d['install_devices'] = cfg['grub_install_devices']
+
+    grubcfg = config.fromdict(config.GrubConfig, grubcfg_d)
 
     LOG.debug("setup grub on target %s", target)
     # if there is storage config, look for devices tagged with 'grub_device'
@@ -763,19 +768,19 @@ def setup_grub(cfg, target, osfamily, variant):
                     get_path_to_storage_volume(item_id, storage_cfg_odict))
 
         if len(storage_grub_devices) > 0:
-            if len(grubcfg.get('install_devices', [])):
+            if grubcfg.install_devices is not None and \
+               len(grubcfg.install_devices) > 0:
                 LOG.warn("Storage Config grub device config takes precedence "
                          "over grub 'install_devices' value, ignoring: %s",
                          grubcfg['install_devices'])
-            grubcfg['install_devices'] = storage_grub_devices
-
-    LOG.debug("install_devices: %s", grubcfg.get('install_devices'))
-    if 'install_devices' in grubcfg:
-        instdevs = grubcfg.get('install_devices')
-        if isinstance(instdevs, str):
-            instdevs = [instdevs]
-        if instdevs is None:
-            LOG.debug("grub installation disabled by config")
+            grubcfg.install_devices = storage_grub_devices
+
+    LOG.debug("install_devices: %s", grubcfg.install_devices)
+    if grubcfg.install_devices is None:
+        LOG.debug("grub installation disabled by config")
+        instdevs = grubcfg.install_devices
+    elif len(grubcfg.install_devices) > 0:
+        instdevs = grubcfg.install_devices
     else:
         # If there were no install_devices found then we try to do the right
         # thing.  That right thing is basically installing on all block
@@ -823,7 +828,7 @@ def setup_grub(cfg, target, osfamily, variant):
     else:
         instdevs = ["none"]
 
-    update_nvram = grubcfg.get('update_nvram', True)
+    update_nvram = grubcfg.update_nvram
     if uefi_bootable and update_nvram:
         efi_orig_state = util.get_efibootmgr(target)
         uefi_remove_old_loaders(grubcfg, target)
diff --git a/curtin/commands/install_grub.py b/curtin/commands/install_grub.py
index 03b4670..2faa2c6 100644
--- a/curtin/commands/install_grub.py
+++ b/curtin/commands/install_grub.py
@@ -3,6 +3,7 @@ import re
 import platform
 import shutil
 import sys
+from typing import List, Optional
 
 from curtin import block
 from curtin import config
@@ -137,7 +138,7 @@ def prepare_grub_dir(target, grub_cfg):
         shutil.move(ci_cfg, ci_cfg + '.disabled')
 
 
-def get_carryover_params(distroinfo):
+def get_carryover_params(distroinfo) -> str:
     # return a string to append to installed systems boot parameters
     # it may include a '--' after a '---'
     # see LP: 1402042 for some history here.
@@ -206,14 +207,17 @@ def replace_grub_cmdline_linux_default(target, new_args):
     LOG.debug('updated %s to set: %s', target_grubconf, newcontent)
 
 
-def write_grub_config(target, grubcfg, grub_conf, new_params):
-    replace_default = config.value_as_boolean(
-        grubcfg.get('replace_linux_default', True))
+def write_grub_config(
+        target: str,
+        grubcfg: config.GrubConfig,
+        grub_conf: str,
+        new_params: str,
+        ) -> None:
+    replace_default = grubcfg.replace_linux_default
     if replace_default:
         replace_grub_cmdline_linux_default(target, new_params)
 
-    probe_os = config.value_as_boolean(
-        grubcfg.get('probe_additional_os', False))
+    probe_os = grubcfg.probe_additional_os
     if not probe_os:
         probe_content = [
             ('# Curtin disable grub os prober that might find other '
@@ -224,10 +228,7 @@ def write_grub_config(target, grubcfg, grub_conf, new_params):
                         "\n".join(probe_content), omode='a+')
 
     # if terminal is present in config, but unset, then don't
-    grub_terminal = grubcfg.get('terminal', 'console')
-    if not isinstance(grub_terminal, str):
-        raise ValueError("Unexpected value %s for 'terminal'. "
-                         "Value must be a string" % grub_terminal)
+    grub_terminal = grubcfg.terminal
     if not grub_terminal.lower() == "unmodified":
         terminal_content = [
             '# Curtin configured GRUB_TERMINAL value',
@@ -394,7 +395,13 @@ def check_target_arch_machine(target, arch=None, machine=None, uefi=None):
         raise RuntimeError(errmsg)
 
 
-def install_grub(devices, target, uefi=None, grubcfg=None):
+def install_grub(
+        devices: List[str],
+        target: str,
+        *,
+        grubcfg: config.GrubConfig,
+        uefi: Optional[bool] = None,
+        ):
     """Install grub to devices inside target chroot.
 
     :param: devices: List of block device paths to install grub upon.
@@ -411,8 +418,8 @@ def install_grub(devices, target, uefi=None, grubcfg=None):
         raise ValueError("Invalid parameter 'target': %s" % target)
 
     LOG.debug("installing grub to target=%s devices=%s [replace_defaults=%s]",
-              target, devices, grubcfg.get('replace_default'))
-    update_nvram = config.value_as_boolean(grubcfg.get('update_nvram', True))
+              target, devices, grubcfg.replace_linux_default)
+    update_nvram = grubcfg.update_nvram
     distroinfo = distro.get_distroinfo(target=target)
     target_arch = distro.get_architecture(target=target)
     rhel_ver = (distro.rpm_get_dist_id(target)
@@ -460,7 +467,7 @@ def install_grub_main(args):
     cfg = config.load_command_config(args, state)
     stack_prefix = state.get('report_stack_prefix', '')
     uefi = util.is_uefi_bootable()
-    grubcfg = cfg.get('grub')
+    grubcfg = config.fromdict(config.GrubConfig, cfg.get('grub'))
     with events.ReportEventStack(
             name=stack_prefix, reporting_enabled=True, level="INFO",
             description="Installing grub to target devices"):
diff --git a/curtin/config.py b/curtin/config.py
index b65410b..39898a4 100644
--- a/curtin/config.py
+++ b/curtin/config.py
@@ -1,6 +1,7 @@
 # This file is part of curtin. See LICENSE file for copyright and license info.
 
 import json
+import typing
 
 import attr
 import yaml
@@ -129,23 +130,142 @@ def value_as_boolean(value):
     return value not in false_values
 
 
+def _convert_install_devices(value):
+    if isinstance(value, str):
+        return [value]
+    return value
+
+
 @attr.s(auto_attribs=True)
 class GrubConfig:
-    # This is not yet every option that appears under the "grub" config key,
-    # but it is a work in progress.
+    install_devices: typing.Optional[typing.List[str]] = attr.ib(
+        converter=_convert_install_devices, default=attr.Factory(list))
+    probe_additional_os: bool = attr.ib(
+        default=False, converter=value_as_boolean)
+    remove_duplicate_entries: bool = True
     remove_old_uefi_loaders: bool = True
     reorder_uefi: bool = True
     reorder_uefi_force_fallback: bool = attr.ib(
         default=False, converter=value_as_boolean)
-    remove_duplicate_entries: bool = True
+    replace_linux_default: bool = attr.ib(
+        default=True, converter=value_as_boolean)
+    terminal: str = "console"
+    update_nvram: bool = attr.ib(default=True, converter=value_as_boolean)
 
 
-def fromdict(cls, d):
-    kw = {}
-    for field in attr.fields(cls):
-        if field.name in d:
-            kw[field.name] = d[field.name]
-    return cls(**kw)
+class SerializationError(Exception):
+    def __init__(self, obj, path, message):
+        self.obj = obj
+        self.path = path
+        self.message = message
+
+    def __str__(self):
+        p = self.path
+        if not p:
+            p = 'top-level'
+        return f"processing {self.obj}: at {p}, {self.message}"
+
+
+@attr.s(auto_attribs=True)
+class SerializationContext:
+    obj: typing.Any
+    cur: typing.Any
+    path: str
+    metadata: typing.Optional[typing.Dict]
+
+    @classmethod
+    def new(cls, obj):
+        return SerializationContext(obj, obj, '', {})
+
+    def child(self, path, cur, metadata=None):
+        if metadata is None:
+            metadata = self.metadata
+        return attr.evolve(
+            self, path=self.path + path, cur=cur, metadata=metadata)
+
+    def error(self, message):
+        raise SerializationError(self.obj, self.path, message)
+
+    def assert_type(self, typ):
+        if type(self.cur) is not typ:
+            self.error("{!r} is not a {}".format(self.cur, typ))
+
+
+class Deserializer:
+
+    def __init__(self):
+        self.typing_walkers = {
+            list: self._walk_List,
+            typing.List: self._walk_List,
+            typing.Union: self._walk_Union,
+            }
+        self.type_deserializers = {}
+        for typ in int, str, bool, list, dict, type(None):
+            self.type_deserializers[typ] = self._scalar
+
+    def _scalar(self, annotation, context):
+        context.assert_type(annotation)
+        return context.cur
+
+    def _walk_List(self, meth, args, context):
+        return [
+            meth(args[0], context.child(f'[{i}]', v))
+            for i, v in enumerate(context.cur)
+            ]
+
+    def _walk_Union(self, meth, args, context):
+        NoneType = type(None)
+        if NoneType in args:
+            args = [a for a in args if a is not NoneType]
+            if len(args) == 1:
+                # I.e. Optional[thing]
+                if context.cur is None:
+                    return context.cur
+                return meth(args[0], context)
+        context.error(f"cannot serialize Union[{args}]")
+
+    def _deserialize_attr(self, annotation, context):
+        context.assert_type(dict)
+        args = {}
+        fields = {
+            field.name: field for field in attr.fields(annotation)
+            }
+        for key, value in context.cur.items():
+            if key not in fields:
+                continue
+            field = fields[key]
+            if field.converter:
+                value = field.converter(value)
+            args[field.name] = self._deserialize(
+                field.type,
+                context.child(f'[{key!r}]', value, field.metadata))
+        return annotation(**args)
+
+    def _deserialize(self, annotation, context):
+        if annotation is None:
+            context.assert_type(type(None))
+            return None
+        if annotation is typing.Any:
+            return context.cur
+        if attr.has(annotation):
+            return self._deserialize_attr(annotation, context)
+        origin = getattr(annotation, '__origin__', None)
+        if origin is not None:
+            return self.typing_walkers[origin](
+                self._deserialize, annotation.__args__, context)
+        return self.type_deserializers[annotation](annotation, context)
+
+    def deserialize(self, annotation, value):
+        context = SerializationContext.new(value)
+        return self._deserialize(annotation, context)
+
+
+T = typing.TypeVar("T")
+
+
+def fromdict(cls: typing.Type[T], d) -> T:
+    deserializer = Deserializer()
+    return deserializer.deserialize(cls, d)
 
 
 # vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_commands_install_grub.py b/tests/unittests/test_commands_install_grub.py
index 00004d7..fc19da3 100644
--- a/tests/unittests/test_commands_install_grub.py
+++ b/tests/unittests/test_commands_install_grub.py
@@ -1,5 +1,6 @@
 # This file is part of curtin. See LICENSE file for copyright and license info.
 
+from curtin import config
 from curtin import distro
 from curtin import util
 from curtin import paths
@@ -456,7 +457,7 @@ class TestWriteGrubConfig(CiTestCase):
                 self.assertEqual(expected, found)
 
     def test_write_grub_config_defaults(self):
-        grubcfg = {}
+        grubcfg = config.GrubConfig()
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -473,7 +474,7 @@ class TestWriteGrubConfig(CiTestCase):
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_no_replace(self):
-        grubcfg = {'replace_linux_default': False}
+        grubcfg = config.GrubConfig(replace_linux_default=False)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([])
         expected_curtin = "\n".join([
@@ -489,7 +490,7 @@ class TestWriteGrubConfig(CiTestCase):
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_disable_probe(self):
-        grubcfg = {'probe_additional_os': False}  # DISABLE_OS_PROBER=1
+        grubcfg = config.GrubConfig(probe_additional_os=False)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -506,7 +507,7 @@ class TestWriteGrubConfig(CiTestCase):
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_enable_probe(self):
-        grubcfg = {'probe_additional_os': True}  # DISABLE_OS_PROBER=0, default
+        grubcfg = config.GrubConfig(probe_additional_os=True)
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -520,10 +521,9 @@ class TestWriteGrubConfig(CiTestCase):
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_no_grub_settings_file(self):
-        grubcfg = {
-            'probe_additional_os': True,
-            'terminal': 'unmodified',
-        }
+        grubcfg = config.GrubConfig(
+            probe_additional_os=True,
+            terminal='unmodified')
         new_params = []
         install_grub.write_grub_config(
             self.target, grubcfg, self.grubconf, new_params)
@@ -531,7 +531,8 @@ class TestWriteGrubConfig(CiTestCase):
         self.assertFalse(os.path.exists(self.target_grubconf))
 
     def test_write_grub_config_specify_terminal(self):
-        grubcfg = {'terminal': 'serial'}
+        grubcfg = config.GrubConfig(
+            terminal='serial')
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -548,7 +549,7 @@ class TestWriteGrubConfig(CiTestCase):
         self._verify_expected(expected_default, expected_curtin)
 
     def test_write_grub_config_terminal_unmodified(self):
-        grubcfg = {'terminal': 'unmodified'}
+        grubcfg = config.GrubConfig(terminal='unmodified')
         new_params = ['foo=bar', 'wark=1']
         expected_default = "\n".join([
              'GRUB_CMDLINE_LINUX_DEFAULT="foo=bar wark=1"', ''])
@@ -562,13 +563,6 @@ class TestWriteGrubConfig(CiTestCase):
 
         self._verify_expected(expected_default, expected_curtin)
 
-    def test_write_grub_config_invalid_terminal(self):
-        grubcfg = {'terminal': ['color-tv']}
-        new_params = ['foo=bar', 'wark=1']
-        with self.assertRaises(ValueError):
-            install_grub.write_grub_config(
-                self.target, grubcfg, self.grubconf, new_params)
-
 
 class TestFindEfiLoader(CiTestCase):
 
@@ -1119,39 +1113,43 @@ class TestInstallGrub(CiTestCase):
     def test_grub_install_raise_exception_on_no_devices(self):
         devices = []
         with self.assertRaises(ValueError):
-            install_grub.install_grub(devices, self.target, False, {})
+            install_grub.install_grub(
+                devices, self.target, uefi=False, grubcfg=config.GrubConfig())
 
     def test_grub_install_raise_exception_on_no_target(self):
         devices = ['foobar']
         with self.assertRaises(ValueError):
-            install_grub.install_grub(devices, None, False, {})
+            install_grub.install_grub(
+                devices, None, uefi=False, grubcfg=config.GrubConfig())
 
     def test_grub_install_raise_exception_on_s390x(self):
         self.m_distro_get_architecture.return_value = 's390x'
         self.m_platform_machine.return_value = 's390x'
         devices = ['foobar']
         with self.assertRaises(RuntimeError):
-            install_grub.install_grub(devices, self.target, False, {})
+            install_grub.install_grub(
+                devices, self.target, uefi=False, grubcfg=config.GrubConfig())
 
     def test_grub_install_raise_exception_on_armv7(self):
         self.m_distro_get_architecture.return_value = 'armhf'
         self.m_platform_machine.return_value = 'armv7l'
         devices = ['foobar']
         with self.assertRaises(RuntimeError):
-            install_grub.install_grub(devices, self.target, False, {})
+            install_grub.install_grub(
+                devices, self.target, uefi=False, grubcfg=config.GrubConfig())
 
     def test_grub_install_raise_exception_on_arm64_no_uefi(self):
         self.m_distro_get_architecture.return_value = 'arm64'
         self.m_platform_machine.return_value = 'aarch64'
-        uefi = False
         devices = ['foobar']
         with self.assertRaises(RuntimeError):
-            install_grub.install_grub(devices, self.target, uefi, {})
+            install_grub.install_grub(
+                devices, self.target, uefi=False, grubcfg=config.GrubConfig())
 
     def test_grub_install_ubuntu(self):
         devices = ['/dev/disk-a-part1']
         uefi = False
-        grubcfg = {}
+        grubcfg = config.GrubConfig()
         grub_conf = self.tmp_path('grubconf')
         new_params = []
         self.m_get_grub_package_name.return_value = ('grub-pc', 'i386-pc')
@@ -1161,7 +1159,8 @@ class TestInstallGrub(CiTestCase):
         self.m_gen_install_commands.return_value = (
             [['/bin/true']], [['/bin/false']])
 
-        install_grub.install_grub(devices, self.target, uefi, grubcfg)
+        install_grub.install_grub(
+            devices, self.target, uefi=uefi, grubcfg=grubcfg)
 
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
@@ -1189,8 +1188,7 @@ class TestInstallGrub(CiTestCase):
     def test_uefi_grub_install_ubuntu(self):
         devices = ['/dev/disk-a-part1']
         uefi = True
-        update_nvram = True
-        grubcfg = {'update_nvram': update_nvram}
+        grubcfg = config.GrubConfig(update_nvram=True)
         grub_conf = self.tmp_path('grubconf')
         new_params = []
         grub_name = 'grub-efi-amd64'
@@ -1203,7 +1201,8 @@ class TestInstallGrub(CiTestCase):
         self.m_gen_uefi_install_commands.return_value = (
             [['/bin/true']], [['/bin/false']])
 
-        install_grub.install_grub(devices, self.target, uefi, grubcfg)
+        install_grub.install_grub(
+            devices, self.target, uefi=uefi, grubcfg=grubcfg)
 
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
@@ -1219,8 +1218,8 @@ class TestInstallGrub(CiTestCase):
         self.m_get_grub_install_command.assert_called_with(
             uefi, self.distroinfo, self.target)
         self.m_gen_uefi_install_commands.assert_called_with(
-            grub_name, grub_target, grub_cmd, update_nvram, self.distroinfo,
-            devices, self.target)
+            grub_name, grub_target, grub_cmd, grubcfg.update_nvram,
+            self.distroinfo, devices, self.target)
 
         self.m_subp.assert_has_calls([
             mock.call(['/bin/true'], env=self.env, capture=True,
@@ -1232,8 +1231,7 @@ class TestInstallGrub(CiTestCase):
     def test_uefi_grub_install_ubuntu_multiple_esp(self):
         devices = ['/dev/disk-a-part1']
         uefi = True
-        update_nvram = True
-        grubcfg = {'update_nvram': update_nvram}
+        grubcfg = config.GrubConfig(update_nvram=True)
         grub_conf = self.tmp_path('grubconf')
         new_params = []
         grub_name = 'grub-efi-amd64'
@@ -1246,7 +1244,8 @@ class TestInstallGrub(CiTestCase):
         self.m_gen_uefi_install_commands.return_value = (
             [['/bin/true']], [['/bin/false']])
 
-        install_grub.install_grub(devices, self.target, uefi, grubcfg)
+        install_grub.install_grub(
+            devices, self.target, uefi=uefi, grubcfg=grubcfg)
 
         self.m_distro_get_distroinfo.assert_called_with(target=self.target)
         self.m_distro_get_architecture.assert_called_with(target=self.target)
@@ -1262,8 +1261,8 @@ class TestInstallGrub(CiTestCase):
         self.m_get_grub_install_command.assert_called_with(
             uefi, self.distroinfo, self.target)
         self.m_gen_uefi_install_commands.assert_called_with(
-            grub_name, grub_target, grub_cmd, update_nvram, self.distroinfo,
-            devices, self.target)
+            grub_name, grub_target, grub_cmd, grubcfg.update_nvram,
+            self.distroinfo, devices, self.target)
 
         self.m_subp.assert_has_calls([
             mock.call(['/bin/true'], env=self.env, capture=True,
diff --git a/tests/unittests/test_config.py b/tests/unittests/test_config.py
index af7f251..ae51744 100644
--- a/tests/unittests/test_config.py
+++ b/tests/unittests/test_config.py
@@ -3,6 +3,9 @@
 import copy
 import json
 import textwrap
+import typing
+
+import attr
 
 from curtin import config
 from .helpers import CiTestCase
@@ -139,4 +142,58 @@ def _replace_consts(cfgstr):
         cfgstr = cfgstr.replace(k, v)
     return cfgstr
 
+
+class TestDeserializer(CiTestCase):
+
+    def test_scalar(self):
+        deserializer = config.Deserializer()
+        self.assertEqual(1, deserializer.deserialize(int, 1))
+        self.assertEqual("a", deserializer.deserialize(str, "a"))
+
+    def test_attr(self):
+        deserializer = config.Deserializer()
+
+        @attr.s(auto_attribs=True)
+        class Point:
+            x: int
+            y: int
+
+        self.assertEqual(
+            Point(x=1, y=2),
+            deserializer.deserialize(Point, {'x': 1, 'y': 2}))
+
+    def test_list(self):
+        deserializer = config.Deserializer()
+        self.assertEqual(
+            [1, 2, 3],
+            deserializer.deserialize(typing.List[int], [1, 2, 3]))
+
+    def test_optional(self):
+        deserializer = config.Deserializer()
+        self.assertEqual(
+            1,
+            deserializer.deserialize(typing.Optional[int], 1))
+        self.assertEqual(
+            None,
+            deserializer.deserialize(typing.Optional[int], None))
+
+    def test_converter(self):
+        deserializer = config.Deserializer()
+
+        @attr.s(auto_attribs=True)
+        class WithoutConverter:
+            val: bool
+
+        with self.assertRaises(config.SerializationError):
+            deserializer.deserialize(WithoutConverter, {"val": "on"})
+
+        @attr.s(auto_attribs=True)
+        class WithConverter:
+            val: bool = attr.ib(converter=config.value_as_boolean)
+
+        self.assertEqual(
+            WithConverter(val=True),
+            deserializer.deserialize(WithConverter, {"val": "on"}))
+
+
 # vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_curthooks.py b/tests/unittests/test_curthooks.py
index 6067ea4..d6b5445 100644
--- a/tests/unittests/test_curthooks.py
+++ b/tests/unittests/test_curthooks.py
@@ -636,13 +636,11 @@ class TestSetupGrub(CiTestCase):
         cfg = {
             'grub_install_devices': ['/dev/vdb']
         }
-        updated_cfg = {
-            'install_devices': ['/dev/vdb']
-        }
         curthooks.setup_grub(cfg, self.target, osfamily=self.distro_family,
                              variant=self.variant)
         self.m_install_grub.assert_called_with(
-            ['/dev/vdb'], self.target, uefi=False, grubcfg=updated_cfg)
+            ['/dev/vdb'], self.target, uefi=False,
+            grubcfg=config.GrubConfig(install_devices=['/dev/vdb']))
 
     def test_uses_install_devices_in_grubcfg(self):
         cfg = {
@@ -654,7 +652,8 @@ class TestSetupGrub(CiTestCase):
             cfg, self.target,
             osfamily=self.distro_family, variant=self.variant)
         self.m_install_grub.assert_called_with(
-            ['/dev/vdb'], self.target, uefi=False, grubcfg=cfg.get('grub'))
+            ['/dev/vdb'], self.target, uefi=False,
+            grubcfg=config.fromdict(config.GrubConfig, cfg.get('grub')))
 
     @patch('curtin.commands.block_meta.multipath')
     @patch('curtin.commands.curthooks.os.path.exists')
@@ -678,7 +677,7 @@ class TestSetupGrub(CiTestCase):
                              variant=self.variant)
         self.m_install_grub.assert_called_with(
             ['/dev/vdb'], self.target, uefi=False,
-            grubcfg={'install_devices': ['/dev/vdb']})
+            grubcfg=config.GrubConfig(install_devices=['/dev/vdb']))
 
     @patch('curtin.commands.block_meta.multipath')
     @patch('curtin.block.is_valid_device')
@@ -729,8 +728,9 @@ class TestSetupGrub(CiTestCase):
                              variant='centos')
         self.m_install_grub.assert_called_with(
             ['/dev/vdb1'], self.target, uefi=True,
-            grubcfg={'update_nvram': False, 'install_devices': ['/dev/vdb1']}
-        )
+            grubcfg=config.GrubConfig(
+                update_nvram=False,
+                install_devices=['/dev/vdb1']))
 
     def test_grub_install_installs_to_none_if_install_devices_None(self):
         cfg = {
@@ -742,7 +742,7 @@ class TestSetupGrub(CiTestCase):
                              variant=self.variant)
         self.m_install_grub.assert_called_with(
             ['none'], self.target, uefi=False,
-            grubcfg={'install_devices': None}
+            grubcfg=config.GrubConfig(install_devices=None),
         )
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
@@ -772,7 +772,8 @@ class TestSetupGrub(CiTestCase):
         curthooks.setup_grub(cfg, self.target, osfamily=self.distro_family,
                              variant=self.variant)
         self.m_install_grub.assert_called_with(
-            ['/dev/vdb'], self.target, uefi=True, grubcfg=cfg.get('grub')
+            ['/dev/vdb'], self.target, uefi=True,
+            grubcfg=config.fromdict(config.GrubConfig, cfg.get('grub'))
         )
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
@@ -1101,7 +1102,7 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries(self):
-        grubcfg = {}
+        grubcfg = config.GrubConfig()
         curthooks.uefi_remove_duplicate_entries(grubcfg, self.target)
         self.assertEqual([
             call(['efibootmgr', '--bootnum=0001', '--delete-bootnum'],
@@ -1112,7 +1113,7 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_no_bootcurrent(self):
-        grubcfg = {}
+        grubcfg = config.GrubConfig()
         efiout = copy_efi_state(self.efibootmgr_output)
         efiout.current = ''
         self.m_efibootmgr.return_value = efiout
@@ -1126,15 +1127,15 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_disabled(self):
-        grubcfg = {
-            'remove_duplicate_entries': False,
-        }
+        grubcfg = config.GrubConfig(
+            remove_duplicate_entries=False,
+            )
         curthooks.uefi_remove_duplicate_entries(grubcfg, self.target)
         self.assertEquals([], self.m_subp.call_args_list)
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_skip_bootcurrent(self):
-        grubcfg = {}
+        grubcfg = config.GrubConfig()
         efiout = copy_efi_state(self.efibootmgr_output)
         efiout.current = '0003'
         self.m_efibootmgr.return_value = efiout
@@ -1148,7 +1149,7 @@ class TestUefiRemoveDuplicateEntries(CiTestCase):
 
     @patch.object(util.ChrootableTarget, "__enter__", new=lambda a: a)
     def test_uefi_remove_duplicate_entries_no_change(self):
-        grubcfg = {}
+        grubcfg = config.GrubConfig()
         self.m_efibootmgr.return_value = util.EFIBootState(
             order=[],
             timeout='',
-- 
Mailing list: https://launchpad.net/~curtin-dev
Post to     : curtin-dev@lists.launchpad.net
Unsubscribe : https://launchpad.net/~curtin-dev
More help   : https://help.launchpad.net/ListHelp

Reply via email to