diff --git a/lib/jsonschema/gen-options-json.sh b/lib/jsonschema/gen-options-json.sh new file mode 100755 index 00000000..727f32ba --- /dev/null +++ b/lib/jsonschema/gen-options-json.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +expr='let pkgs = import {}; lib = pkgs.lib; in (pkgs.nixosOptionsDoc {options = (lib.evalModules {modules=[./example-interface.nix];}).options;}).optionsJSON.options' + +jq < "$(nix eval --impure --raw --expr "$expr")" > options.json diff --git a/lib/jsonschema/options.json b/lib/jsonschema/options.json new file mode 100644 index 00000000..0b8da527 --- /dev/null +++ b/lib/jsonschema/options.json @@ -0,0 +1,104 @@ +{ + "age": { + "declarations": [ + "/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix" + ], + "default": { + "_type": "literalExpression", + "text": "42" + }, + "description": "The age of the user", + "loc": [ + "age" + ], + "readOnly": false, + "type": "signed integer" + }, + "isAdmin": { + "declarations": [ + "/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix" + ], + "default": { + "_type": "literalExpression", + "text": "false" + }, + "description": "Is the user an admin?", + "loc": [ + "isAdmin" + ], + "readOnly": false, + "type": "boolean" + }, + "kernelModules": { + "declarations": [ + "/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix" + ], + "default": { + "_type": "literalExpression", + "text": "[\n \"nvme\"\n \"xhci_pci\"\n \"ahci\"\n]" + }, + "description": "A list of enabled kernel modules", + "loc": [ + "kernelModules" + ], + "readOnly": false, + "type": "list of string" + }, + "name": { + "declarations": [ + "/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix" + ], + "default": { + "_type": "literalExpression", + "text": "\"John Doe\"" + }, + "description": "The name of the user", + "loc": [ + "name" + ], + "readOnly": false, + "type": "string" + }, + "services": { + "declarations": [ + "/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix" + ], + "description": null, + "loc": [ + "services" + ], + "readOnly": false, + "type": "submodule" + }, + "services.opt": { + "declarations": [ + "/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix" + ], + "default": { + "_type": "literalExpression", + "text": "\"foo\"" + }, + "description": "A submodule option", + "loc": [ + "services", + "opt" + ], + "readOnly": false, + "type": "string" + }, + "userIds": { + "declarations": [ + "/home/grmpf/synced/projects/clan/clan-core/lib/jsonschema/example-interface.nix" + ], + "default": { + "_type": "literalExpression", + "text": "{\n albrecht = 3;\n horst = 1;\n peter = 2;\n}" + }, + "description": "Some attributes", + "loc": [ + "userIds" + ], + "readOnly": false, + "type": "attribute set of signed integer" + } +} diff --git a/pkgs/clan-cli/clan_cli/config/__init__.py b/pkgs/clan-cli/clan_cli/config/__init__.py index aef2e45b..65ffcc08 100644 --- a/pkgs/clan-cli/clan_cli/config/__init__.py +++ b/pkgs/clan-cli/clan_cli/config/__init__.py @@ -7,11 +7,27 @@ from typing import Any, Optional, Type from clan_cli.errors import ClanError -from . import parsing - script_dir = Path(__file__).parent +# nixos option type description to python type +def map_type(type: str) -> Type: + if type == "boolean": + return bool + elif type in ["integer", "signed integer"]: + return int + elif type == "string": + return str + elif type.startswith("attribute set of"): + subtype = type.removeprefix("attribute set of ") + return dict[str, map_type(subtype)] # type: ignore + elif type.startswith("list of"): + subtype = type.removeprefix("list of ") + return list[map_type(subtype)] # type: ignore + else: + raise ClanError(f"Unknown type {type}") + + class Kwargs: def __init__(self) -> None: self.type: Optional[Type] = None @@ -30,31 +46,66 @@ class AllContainer(list): return True -def process_args(args: argparse.Namespace, schema: dict) -> None: - option = args.option - value_arg = args.value - +def process_args(option: str, value: Any, options: dict) -> None: 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} not found") + option_parent = option_path[:-1] + attr = option_path[-1] + return process_args( + option=".".join(option_parent), + value={attr: value}, + options=options, + ) + + target_type = map_type(options[option]["type"]) + # construct a nested dict from the option path and set the value result: dict[str, Any] = {} current = result for part in option_path[:-1]: current[part] = {} current = current[part] - current[option_path[-1]] = value_arg + current[option_path[-1]] = value - # validate the result against the schema and cast the value to the expected type - schema_type = parsing.type_from_schema_path(schema, option_path) + # 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) -> Any: + try: + # handle bools + if isinstance(type(), bool): + if value == "true": + return True + elif value == "false": + return False + else: + raise ClanError(f"Invalid value {value} for boolean") + # handle lists + elif isinstance(type(), list): + subtype = type.__args__[0] + return [cast([x], subtype) for x in value] + # handle dicts + elif isinstance(type(), dict): + if not isinstance(value, dict): + raise ClanError( + f"Cannot set {option} directly. Specify a suboption like {option}." + ) + subtype = type.__args__[1] + return {k: cast(v, subtype) for k, v in value.items()} + else: + if len(value) > 1: + raise ClanError(f"Too many values for {option}") + return type(value[0]) + except ValueError: + raise ClanError( + f"Invalid type for option {option} (expected {type.__name__})" + ) - # we use nargs="+", so we need to unwrap non-list values - if isinstance(schema_type(), list): - subtype = schema_type.__args__[0] - casted = [subtype(x) for x in value_arg] - elif isinstance(schema_type(), dict): - subtype = schema_type.__args__[1] - raise ClanError("Dicts are not supported") - else: - casted = schema_type(value_arg[0]) + casted = cast(value, target_type) current[option_path[-1]] = casted @@ -64,34 +115,28 @@ def process_args(args: argparse.Namespace, schema: dict) -> None: def register_parser( parser: argparse.ArgumentParser, - file: Path = Path(f"{script_dir}/jsonschema/example-schema.json"), + file: Path = Path(f"{script_dir}/jsonschema/options.json"), ) -> None: - if file.name.endswith(".nix"): - schema = parsing.schema_from_module_file(file) - else: - schema = json.loads(file.read_text()) - return _register_parser(parser, schema) + options = json.loads(file.read_text()) + return _register_parser(parser, options) # takes a (sub)parser and configures it def _register_parser( parser: Optional[argparse.ArgumentParser], - schema: dict[str, Any], + options: dict[str, Any], ) -> None: - # check if schema is a .nix file and load it in that case - if "type" not in schema: - raise ClanError("Schema has no type") - if schema["type"] != "object": - raise ClanError("Schema is not an object") - if parser is None: - parser = argparse.ArgumentParser(description=schema.get("description")) - - # get all possible options from the schema - options = parsing.options_types_from_schema(schema) + parser = argparse.ArgumentParser( + description="Set or show NixOS options", + ) # inject callback function to process the input later - parser.set_defaults(func=lambda args: process_args(args, schema=schema)) + parser.set_defaults( + func=lambda args: process_args( + option=args.option, value=args.value, options=options + ) + ) # add single positional argument for the option (e.g. "foo.bar") parser.add_argument( diff --git a/pkgs/clan-cli/tests/test_config.py b/pkgs/clan-cli/tests/test_config.py index 82eb8246..bee94e1b 100644 --- a/pkgs/clan-cli/tests/test_config.py +++ b/pkgs/clan-cli/tests/test_config.py @@ -9,7 +9,7 @@ import pytest from clan_cli import config from clan_cli.config import parsing -example_schema = f"{Path(config.__file__).parent}/jsonschema/example-schema.json" +example_schema = f"{Path(config.__file__).parent}/jsonschema/options.json" # use pytest.parametrize