clan-config: stop using jsonschema at all for CLI
- instead use options.json coming from nixosOptionsDoc
This commit is contained in:
6
lib/jsonschema/gen-options-json.sh
Executable file
6
lib/jsonschema/gen-options-json.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
expr='let pkgs = import <nixpkgs> {}; 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
|
||||||
104
lib/jsonschema/options.json
Normal file
104
lib/jsonschema/options.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,27 @@ from typing import Any, Optional, Type
|
|||||||
|
|
||||||
from clan_cli.errors import ClanError
|
from clan_cli.errors import ClanError
|
||||||
|
|
||||||
from . import parsing
|
|
||||||
|
|
||||||
script_dir = Path(__file__).parent
|
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:
|
class Kwargs:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.type: Optional[Type] = None
|
self.type: Optional[Type] = None
|
||||||
@@ -30,31 +46,66 @@ class AllContainer(list):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def process_args(args: argparse.Namespace, schema: dict) -> None:
|
def process_args(option: str, value: Any, options: dict) -> None:
|
||||||
option = args.option
|
|
||||||
value_arg = args.value
|
|
||||||
|
|
||||||
option_path = option.split(".")
|
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
|
# construct a nested dict from the option path and set the value
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
current = result
|
current = result
|
||||||
for part in option_path[:-1]:
|
for part in option_path[:-1]:
|
||||||
current[part] = {}
|
current[part] = {}
|
||||||
current = 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
|
# value is always a list, as the arg parser cannot know the type upfront
|
||||||
schema_type = parsing.type_from_schema_path(schema, option_path)
|
# and therefore always allows multiple arguments.
|
||||||
|
def cast(value: Any, type: Type) -> Any:
|
||||||
# we use nargs="+", so we need to unwrap non-list values
|
try:
|
||||||
if isinstance(schema_type(), list):
|
# handle bools
|
||||||
subtype = schema_type.__args__[0]
|
if isinstance(type(), bool):
|
||||||
casted = [subtype(x) for x in value_arg]
|
if value == "true":
|
||||||
elif isinstance(schema_type(), dict):
|
return True
|
||||||
subtype = schema_type.__args__[1]
|
elif value == "false":
|
||||||
raise ClanError("Dicts are not supported")
|
return False
|
||||||
else:
|
else:
|
||||||
casted = schema_type(value_arg[0])
|
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}.<name>"
|
||||||
|
)
|
||||||
|
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__})"
|
||||||
|
)
|
||||||
|
|
||||||
|
casted = cast(value, target_type)
|
||||||
|
|
||||||
current[option_path[-1]] = casted
|
current[option_path[-1]] = casted
|
||||||
|
|
||||||
@@ -64,34 +115,28 @@ def process_args(args: argparse.Namespace, schema: dict) -> None:
|
|||||||
|
|
||||||
def register_parser(
|
def register_parser(
|
||||||
parser: argparse.ArgumentParser,
|
parser: argparse.ArgumentParser,
|
||||||
file: Path = Path(f"{script_dir}/jsonschema/example-schema.json"),
|
file: Path = Path(f"{script_dir}/jsonschema/options.json"),
|
||||||
) -> None:
|
) -> None:
|
||||||
if file.name.endswith(".nix"):
|
options = json.loads(file.read_text())
|
||||||
schema = parsing.schema_from_module_file(file)
|
return _register_parser(parser, options)
|
||||||
else:
|
|
||||||
schema = json.loads(file.read_text())
|
|
||||||
return _register_parser(parser, schema)
|
|
||||||
|
|
||||||
|
|
||||||
# takes a (sub)parser and configures it
|
# takes a (sub)parser and configures it
|
||||||
def _register_parser(
|
def _register_parser(
|
||||||
parser: Optional[argparse.ArgumentParser],
|
parser: Optional[argparse.ArgumentParser],
|
||||||
schema: dict[str, Any],
|
options: dict[str, Any],
|
||||||
) -> None:
|
) -> 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:
|
if parser is None:
|
||||||
parser = argparse.ArgumentParser(description=schema.get("description"))
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Set or show NixOS options",
|
||||||
# get all possible options from the schema
|
)
|
||||||
options = parsing.options_types_from_schema(schema)
|
|
||||||
|
|
||||||
# inject callback function to process the input later
|
# 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")
|
# add single positional argument for the option (e.g. "foo.bar")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import pytest
|
|||||||
from clan_cli import config
|
from clan_cli import config
|
||||||
from clan_cli.config import parsing
|
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
|
# use pytest.parametrize
|
||||||
|
|||||||
Reference in New Issue
Block a user