From ec70b34470c85e8a1a34dde0a3d3db3bd6492aeb Mon Sep 17 00:00:00 2001 From: DavHau Date: Sun, 24 Sep 2023 13:04:18 +0100 Subject: [PATCH] clan config: match dynamic options containing --- pkgs/clan-cli/clan_cli/__init__.py | 4 +- pkgs/clan-cli/clan_cli/config/__init__.py | 76 ++++++++++++++++++----- pkgs/clan-cli/tests/test_config.py | 29 +++++++++ 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/__init__.py b/pkgs/clan-cli/clan_cli/__init__.py index 39c9f1ad9..7956c8bdb 100644 --- a/pkgs/clan-cli/clan_cli/__init__.py +++ b/pkgs/clan-cli/clan_cli/__init__.py @@ -25,7 +25,9 @@ def create_parser(prog: Optional[str] = None) -> argparse.ArgumentParser: subparsers = parser.add_subparsers() - parser_create = subparsers.add_parser("create", help="create a clan flake inside the current directory") + parser_create = subparsers.add_parser( + "create", help="create a clan flake inside the current directory" + ) create.register_parser(parser_create) parser_config = subparsers.add_parser("config", help="set nixos configuration") diff --git a/pkgs/clan-cli/clan_cli/config/__init__.py b/pkgs/clan-cli/clan_cli/config/__init__.py index cc119a911..d899f2375 100644 --- a/pkgs/clan-cli/clan_cli/config/__init__.py +++ b/pkgs/clan-cli/clan_cli/config/__init__.py @@ -2,11 +2,12 @@ import argparse import json import os +import re import shlex import subprocess import sys from pathlib import Path -from typing import Any, Optional, Type +from typing import Any, Optional, Tuple, Type from clan_cli.dirs import get_clan_flake_toplevel from clan_cli.errors import ClanError @@ -181,6 +182,57 @@ def get_or_set_option(args: argparse.Namespace) -> None: print(new_value) +def find_option( + option: str, value: Any, options: dict, option_description: Optional[str] = None +) -> Tuple[str, Any]: + """ + The option path specified by the user doesn't have to match exactly to an + entry in the options.json file. Examples + + Example 1: + $ clan config services.openssh.settings.SomeSetting 42 + This is a freeform option that does not appear in the options.json + The actual option is `services.openssh.settings` + And the value must be wrapped: {"SomeSettings": 42} + + Example 2: + $ clan config users.users.my-user.name my-name + The actual option is `users.users..name` + """ + + # option description is used for error messages + if option_description is None: + option_description = option + + option_path = option.split(".") + + # fuzzy search the option paths, so when + # specified option path: "foo.bar.baz.bum" + # available option path: "foo..baz." + # we can still find the option + first = option_path[0] + regex = rf"({first}|)" + for elem in option_path[1:]: + regex += rf"\.({elem}|)" + for opt in options.keys(): + if re.match(regex, opt): + return opt, value + + # if the regex search did not find the option, start stripping the last + # element of the option path and find matching parent option + # (see examples above for why this is needed) + if len(option_path) == 1: + raise ClanError(f"Option {option_description} not found") + option_path_parent = option_path[:-1] + attr_prefix = option_path[-1] + return find_option( + option=".".join(option_path_parent), + value={attr_prefix: value}, + options=options, + option_description=option_description, + ) + + def set_option( option: str, value: Any, @@ -189,24 +241,14 @@ def set_option( option_description: str = "", show_trace: bool = False, ) -> None: + option, value = find_option( + option=option, + value=value, + options=options, + option_description=option_description, + ) option_path = option.split(".") - # if the option cannot be found, then likely the type is attrs and we need to - # find the parent option. - if option not in options: - if len(option_path) == 1: - raise ClanError(f"Option {option_description} not found") - option_parent = option_path[:-1] - attr = option_path[-1] - return set_option( - option=".".join(option_parent), - value={attr: value}, - options=options, - settings_file=settings_file, - option_description=option, - show_trace=show_trace, - ) - target_type = map_type(options[option]["type"]) casted = cast(value, target_type, option) diff --git a/pkgs/clan-cli/tests/test_config.py b/pkgs/clan-cli/tests/test_config.py index 049b12aa3..12d10d4fd 100644 --- a/pkgs/clan-cli/tests/test_config.py +++ b/pkgs/clan-cli/tests/test_config.py @@ -177,3 +177,32 @@ def test_type_from_schema_path_dynamic_attrs() -> None: # test the cast function with simple types def test_cast_simple() -> None: assert config.cast(["true"], bool, "foo-option") is True + + +@pytest.mark.parametrize( + "option,value,options,expected", + [ + ("foo.bar", ["baz"], {"foo.bar": {"type": "str"}}, ("foo.bar", ["baz"])), + ("foo.bar", ["baz"], {"foo": {"type": "attrs"}}, ("foo", {"bar": ["baz"]})), + ( + "users.users.my-user.name", + ["my-name"], + {"users.users..name": {"type": "str"}}, + ("users.users..name", ["my-name"]), + ), + ( + "foo.bar.baz.bum", + ["val"], + {"foo..baz": {"type": "attrs"}}, + ("foo..baz", {"bum": ["val"]}), + ), + ( + "userIds.DavHau", + ["42"], + {"userIds": {"type": "attrs"}}, + ("userIds", {"DavHau": ["42"]}), + ), + ], +) +def test_find_option(option: str, value: list, options: dict, expected: tuple) -> None: + assert config.find_option(option, value, options) == expected