From 3783359f0868c21f705e8b2072a3ce828e1e0687 Mon Sep 17 00:00:00 2001 From: DavHau Date: Sun, 24 Sep 2023 14:24:32 +0100 Subject: [PATCH] clan config: support new types nullOr and passwdEntry --- pkgs/clan-cli/clan_cli/config/__init__.py | 35 +++++++++++++++++------ pkgs/clan-cli/tests/test_config.py | 26 +++++++++++++++-- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/pkgs/clan-cli/clan_cli/config/__init__.py b/pkgs/clan-cli/clan_cli/config/__init__.py index d899f2375..896f1021e 100644 --- a/pkgs/clan-cli/clan_cli/config/__init__.py +++ b/pkgs/clan-cli/clan_cli/config/__init__.py @@ -7,7 +7,7 @@ import shlex import subprocess import sys from pathlib import Path -from typing import Any, Optional, Tuple, Type +from typing import Any, Optional, Tuple, get_origin from clan_cli.dirs import get_clan_flake_toplevel from clan_cli.errors import ClanError @@ -19,7 +19,7 @@ script_dir = Path(__file__).parent # nixos option type description to python type -def map_type(type: str) -> Type: +def map_type(type: str) -> Any: if type == "boolean": return bool elif type in [ @@ -30,6 +30,12 @@ def map_type(type: str) -> Type: return int elif type == "string": return str + # lib.type.passwdEntry + elif type == "string, not containing newlines or colons": + return str + elif type.startswith("null or "): + subtype = type.removeprefix("null or ") + return Optional[map_type(subtype)] elif type.startswith("attribute set of"): subtype = type.removeprefix("attribute set of ") return dict[str, map_type(subtype)] # type: ignore @@ -65,10 +71,10 @@ class AllContainer(list): # value is always a list, as the arg parser cannot know the type upfront # and therefore always allows multiple arguments. -def cast(value: Any, type: Type, opt_description: str) -> Any: +def cast(value: Any, type: Any, opt_description: str) -> Any: try: # handle bools - if isinstance(type(), bool): + if isinstance(type, bool): if value[0] in ["true", "True", "yes", "y", "1"]: return True elif value[0] in ["false", "False", "no", "n", "0"]: @@ -76,17 +82,21 @@ def cast(value: Any, type: Type, opt_description: str) -> Any: else: raise ClanError(f"Invalid value {value} for boolean") # handle lists - elif isinstance(type(), list): + elif get_origin(type) == list: subtype = type.__args__[0] return [cast([x], subtype, opt_description) for x in value] # handle dicts - elif isinstance(type(), dict): + elif get_origin(type) == dict: if not isinstance(value, dict): raise ClanError( f"Cannot set {opt_description} directly. Specify a suboption like {opt_description}." ) subtype = type.__args__[1] return {k: cast(v, subtype, opt_description) for k, v in value.items()} + elif str(type) == "typing.Optional[str]": + if value[0] in ["null", "None"]: + return None + return value[0] else: if len(value) > 1: raise ClanError(f"Too many values for {opt_description}") @@ -241,6 +251,11 @@ def set_option( option_description: str = "", show_trace: bool = False, ) -> None: + option_path_orig = option.split(".") + + # returns for example: + # option: "users.users..name" + # value: "my-name" option, value = find_option( option=option, value=value, @@ -249,18 +264,20 @@ def set_option( ) option_path = option.split(".") + option_path_store = option_path_orig[: len(option_path)] + target_type = map_type(options[option]["type"]) casted = cast(value, target_type, option) # construct a nested dict from the option path and set the value result: dict[str, Any] = {} current = result - for part in option_path[:-1]: + for part in option_path_store[:-1]: current[part] = {} current = current[part] - current[option_path[-1]] = value + current[option_path_store[-1]] = value - current[option_path[-1]] = casted + current[option_path_store[-1]] = casted # check if there is an existing config file if os.path.exists(settings_file): diff --git a/pkgs/clan-cli/tests/test_config.py b/pkgs/clan-cli/tests/test_config.py index 12d10d4fd..796a798bd 100644 --- a/pkgs/clan-cli/tests/test_config.py +++ b/pkgs/clan-cli/tests/test_config.py @@ -1,13 +1,14 @@ import json import tempfile from pathlib import Path -from typing import Any +from typing import Any, Optional import pytest from cli import Cli from clan_cli import config from clan_cli.config import parsing +from clan_cli.errors import ClanError example_options = f"{Path(config.__file__).parent}/jsonschema/options.json" @@ -174,9 +175,28 @@ def test_type_from_schema_path_dynamic_attrs() -> None: assert parsing.type_from_schema_path(schema, ["users", "foo"]) == str +def test_map_type() -> None: + with pytest.raises(ClanError): + config.map_type("foo") + assert config.map_type("string") == str + assert config.map_type("integer") == int + assert config.map_type("boolean") == bool + assert config.map_type("attribute set of string") == dict[str, str] + assert config.map_type("attribute set of integer") == dict[str, int] + assert config.map_type("null or string") == Optional[str] + + # test the cast function with simple types -def test_cast_simple() -> None: - assert config.cast(["true"], bool, "foo-option") is True +def test_cast() -> None: + assert config.cast(value=["true"], type=bool, opt_description="foo-option") is True + assert ( + config.cast(value=["null"], type=Optional[str], opt_description="foo-option") + is None + ) + assert ( + config.cast(value=["bar"], type=Optional[str], opt_description="foo-option") + == "bar" + ) @pytest.mark.parametrize(