Merge pull request 'feat(docs,api): expose inventory.instances interface' (#3721) from hsjobeki/clan-core:inventory-services-1 into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3721
This commit is contained in:
hsjobeki
2025-05-20 15:29:14 +00:00
7 changed files with 166 additions and 56 deletions

View File

@@ -359,9 +359,6 @@ in
}; };
instances = lib.mkOption { instances = lib.mkOption {
# Keep as internal until all de-/serialization issues are resolved
visible = false;
internal = true;
description = "Multi host service module instances"; description = "Multi host service module instances";
type = types.attrsOf ( type = types.attrsOf (
types.submodule { types.submodule {
@@ -402,14 +399,7 @@ in
default = { }; default = { };
}; };
tags = lib.mkOption { tags = lib.mkOption {
type = types.attrsOf ( type = types.attrsOf (types.submodule { });
types.submodule {
options.settings = lib.mkOption {
default = { };
type = types.deferredModule;
};
}
);
default = { }; default = { };
}; };
settings = lib.mkOption { settings = lib.mkOption {

View File

@@ -304,6 +304,17 @@ rec {
# return jsonschema property definition for raw # return jsonschema property definition for raw
then then
exposedModuleInfo // default // example // description // { type = allBasicTypes; } exposedModuleInfo // default // example // description // { type = allBasicTypes; }
else if
# This is a special case for the deferred clan.service 'settings', we assume it is JSON serializable
# To get the type of a Deferred modules we need to know the interface of the place where it is evaluated.
# i.e. in case of a clan.service this is the interface of the service which dynamically changes depending on the service
# We assign "type" = []
# This means any value is valid — or like TypeScripts unknown.
# We can assign the type later, when we know the exact interface.
# tsType = "unknown" is a type that we preload for json2ts, such that it gets the correct type in typescript
(option.type.name == "deferredModule")
then
exposedModuleInfo // default // example // description // { tsType = "unknown"; }
# parse enum # parse enum
else if else if
option.type.name == "enum" option.type.name == "enum"

View File

@@ -294,3 +294,31 @@ def test_enum_roundtrip() -> None:
assert from_dict(Person, data2) == expected2 assert from_dict(Person, data2) == expected2
assert dataclass_to_dict(expected2) == data2 assert dataclass_to_dict(expected2) == data2
# for the test below
# we would import this from the nix_models
class Unknown:
pass
def test_unknown_deserialize() -> None:
@dataclass
class Person:
name: Unknown
data = {"name": ["a", "b"]}
person = from_dict(Person, data)
person.name = ["a", "b"]
def test_unknown_serialize() -> None:
@dataclass
class Person:
name: Unknown
data = Person(["a", "b"]) # type: ignore
person = dataclass_to_dict(data)
assert person == {"name": ["a", "b"]}

View File

@@ -30,6 +30,7 @@ Note: This module assumes the presence of other modules and classes such as `Cla
""" """
import dataclasses import dataclasses
import inspect
from dataclasses import dataclass, fields, is_dataclass from dataclasses import dataclass, fields, is_dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -261,6 +262,10 @@ def construct_value(
return t(field_value) # type: ignore return t(field_value) # type: ignore
if inspect.isclass(t) and t.__name__ == "Unknown":
# Return the field value as is
return field_value
msg = f"Unhandled field type {t} with value {field_value}" msg = f"Unhandled field type {t} with value {field_value}"
raise ClanError(msg) raise ClanError(msg)

View File

@@ -1,5 +1,6 @@
import copy import copy
import dataclasses import dataclasses
import inspect
import pathlib import pathlib
from dataclasses import MISSING from dataclasses import MISSING
from enum import EnumType from enum import EnumType
@@ -110,6 +111,12 @@ def type_to_dict(
if t is None: if t is None:
return {"type": "null"} return {"type": "null"}
if inspect.isclass(t) and t.__name__ == "Unknown":
# Empty should represent unknown
# We don't know anything about this type
# Nor about the nested fields, if there are any
return {}
if dataclasses.is_dataclass(t): if dataclasses.is_dataclass(t):
fields = dataclasses.fields(t) fields = dataclasses.fields(t)
properties = {} properties = {}

View File

@@ -8,53 +8,104 @@
from typing import Any, Literal, NotRequired, TypedDict from typing import Any, Literal, NotRequired, TypedDict
# Mimic "unknown".
# 'Any' is unsafe because it allows any operations
# This forces the user to use type-narrowing or casting in the code
class Unknown:
pass
InstanceModuleNameType = str
InstanceModuleInputType = str
class InstanceModule(TypedDict):
name: str
input: NotRequired[InstanceModuleInputType]
InstanceRoleMachineSettingsType = Unknown
class InstanceRoleMachine(TypedDict):
settings: NotRequired[InstanceRoleMachineSettingsType]
class InstanceRoleTag(TypedDict):
pass
InstanceRoleMachinesType = dict[str, InstanceRoleMachine]
InstanceRoleSettingsType = Unknown
InstanceRoleTagsType = dict[str, InstanceRoleTag]
class InstanceRole(TypedDict):
machines: NotRequired[InstanceRoleMachinesType]
settings: NotRequired[InstanceRoleSettingsType]
tags: NotRequired[InstanceRoleTagsType]
InstanceModuleType = InstanceModule
InstanceRolesType = dict[str, InstanceRole]
class Instance(TypedDict):
module: NotRequired[InstanceModuleType]
roles: NotRequired[InstanceRolesType]
MachineDeployTargethostType = str
class MachineDeploy(TypedDict): class MachineDeploy(TypedDict):
targetHost: NotRequired[str] targetHost: NotRequired[MachineDeployTargethostType]
MachineDeployTargethostType = NotRequired[str]
MachineDeployType = MachineDeploy
MachineDescriptionType = str
MachineIconType = str
MachineMachineclassType = Literal["nixos", "darwin"]
MachineNameType = str
MachineTagsType = list[str]
class Machine(TypedDict): class Machine(TypedDict):
deploy: NotRequired[MachineDeploy] deploy: NotRequired[MachineDeployType]
description: NotRequired[str] description: NotRequired[MachineDescriptionType]
icon: NotRequired[str] icon: NotRequired[MachineIconType]
machineClass: NotRequired[Literal["nixos", "darwin"]] machineClass: NotRequired[MachineMachineclassType]
name: NotRequired[str] name: NotRequired[MachineNameType]
tags: NotRequired[list[str]] tags: NotRequired[MachineTagsType]
MachineDeployType = NotRequired[MachineDeploy]
MachineDescriptionType = NotRequired[str]
MachineIconType = NotRequired[str]
MachineMachineclassType = NotRequired[Literal["nixos", "darwin"]]
MachineNameType = NotRequired[str]
MachineTagsType = NotRequired[list[str]]
MetaNameType = str
MetaDescriptionType = str
MetaIconType = str
class Meta(TypedDict): class Meta(TypedDict):
name: str name: str
description: NotRequired[str] description: NotRequired[MetaDescriptionType]
icon: NotRequired[str] icon: NotRequired[MetaIconType]
MetaNameType = str
MetaDescriptionType = NotRequired[str]
MetaIconType = NotRequired[str]
Service = dict[str, Any] Service = dict[str, Any]
InventoryInstancesType = dict[str, Instance]
InventoryMachinesType = dict[str, Machine]
InventoryMetaType = Meta
InventoryModulesType = dict[str, Any]
InventoryServicesType = dict[str, Service]
InventoryTagsType = dict[str, Any]
class Inventory(TypedDict): class Inventory(TypedDict):
machines: NotRequired[dict[str, Machine]] instances: NotRequired[InventoryInstancesType]
meta: NotRequired[Meta] machines: NotRequired[InventoryMachinesType]
modules: NotRequired[dict[str, Any]] meta: NotRequired[InventoryMetaType]
services: NotRequired[dict[str, Service]] modules: NotRequired[InventoryModulesType]
tags: NotRequired[dict[str, Any]] services: NotRequired[InventoryServicesType]
tags: NotRequired[InventoryTagsType]
InventoryMachinesType = NotRequired[dict[str, Machine]]
InventoryMetaType = NotRequired[Meta]
InventoryModulesType = NotRequired[dict[str, Any]]
InventoryServicesType = NotRequired[dict[str, Service]]
InventoryTagsType = NotRequired[dict[str, Any]]

View File

@@ -1,12 +1,15 @@
# ruff: noqa: RUF001 # ruff: noqa: RUF001
import argparse import argparse
import json import json
import logging
import sys import sys
from collections.abc import Callable from collections.abc import Callable
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
logger = logging.getLogger(__name__)
class Error(Exception): class Error(Exception):
pass pass
@@ -56,7 +59,7 @@ known_classes = set()
root_class = "Inventory" root_class = "Inventory"
# TODO: make this configurable # TODO: make this configurable
# For now this only includes static top-level attributes of the inventory. # For now this only includes static top-level attributes of the inventory.
attrs = ["machines", "meta", "services"] attrs = ["machines", "meta", "services", "instances"]
static: dict[str, str] = {"Service": "dict[str, Any]"} static: dict[str, str] = {"Service": "dict[str, Any]"}
@@ -130,6 +133,12 @@ def field_def_from_default_value(
finalize_field: Callable[..., tuple[str, str]], finalize_field: Callable[..., tuple[str, str]],
) -> tuple[str, str] | None: ) -> tuple[str, str] | None:
# default_value = prop_info.get("default") # default_value = prop_info.get("default")
if "Unknown" in field_types:
# Unknown type, doesnt matter what the default value is
# type Unknown | a -> Unknown
return finalize_field(
field_types=field_types,
)
if default_value is None: if default_value is None:
return finalize_field( return finalize_field(
field_types=field_types | {"None"}, field_types=field_types | {"None"},
@@ -189,7 +198,7 @@ def get_field_def(
if "None" in field_types: if "None" in field_types:
field_types.remove("None") field_types.remove("None")
serialised_types = " | ".join(field_types) + type_appendix serialised_types = " | ".join(field_types) + type_appendix
serialised_types = f"NotRequired[{serialised_types}]" serialised_types = f"{serialised_types}"
else: else:
serialised_types = " | ".join(field_types) + type_appendix serialised_types = " | ".join(field_types) + type_appendix
@@ -218,7 +227,7 @@ def generate_dataclass(
field_name = prop.replace("-", "_") field_name = prop.replace("-", "_")
if len(attr_path) == 0 and prop not in attrs: if len(attr_path) == 0 and prop not in attrs:
field_def = field_name, "NotRequired[dict[str, Any]]" field_def = field_name, "dict[str, Any]"
fields_with_default.append(field_def) fields_with_default.append(field_def)
# breakpoint() # breakpoint()
continue continue
@@ -235,8 +244,9 @@ def generate_dataclass(
nested_class_name = f"""{class_name if class_name != root_class and not prop_info.get("title") else ""}{title_sanitized}""" nested_class_name = f"""{class_name if class_name != root_class and not prop_info.get("title") else ""}{title_sanitized}"""
if not prop_type and not union_variants and not enum_variants: if not prop_type and not union_variants and not enum_variants:
msg = f"Type not found for property {prop} {prop_info}" msg = f"Type not found for property {prop} {prop_info}.\nConverting to unknown type.\n"
raise Error(msg) logger.warning(msg)
prop_type = "Unknown"
if union_variants: if union_variants:
field_types = map_json_type(union_variants) field_types = map_json_type(union_variants)
@@ -283,6 +293,8 @@ def generate_dataclass(
) )
) )
known_classes.add(nested_class_name) known_classes.add(nested_class_name)
elif prop_type == "Unknown":
field_types = {"Unknown"}
else: else:
field_types = map_json_type( field_types = map_json_type(
prop_type, prop_type,
@@ -347,8 +359,9 @@ def generate_dataclass(
# Join field name with type to form a complete field declaration # Join field name with type to form a complete field declaration
# e.g. "name: str" # e.g. "name: str"
all_field_declarations = [ all_field_declarations = [f"{n}: {t}" for n, t in (required_fields)] + [
f"{n}: {t}" for n, t in (required_fields + fields_with_default) f"{n}: NotRequired[{class_name}{n.capitalize()}Type]"
for n, t in (fields_with_default)
] ]
hoisted_types: str = "\n".join( hoisted_types: str = "\n".join(
[ [
@@ -359,14 +372,13 @@ def generate_dataclass(
fields_str = "\n ".join(all_field_declarations) fields_str = "\n ".join(all_field_declarations)
nested_classes_str = "\n\n".join(nested_classes) nested_classes_str = "\n\n".join(nested_classes)
class_def = f"\nclass {class_name}(TypedDict):\n" class_def = f"\n\n{hoisted_types}\n"
class_def += f"\nclass {class_name}(TypedDict):\n"
if not required_fields + fields_with_default: if not required_fields + fields_with_default:
class_def += " pass" class_def += " pass"
else: else:
class_def += f" {fields_str}" class_def += f" {fields_str}"
class_def += f"\n\n{hoisted_types}\n"
return f"{nested_classes_str}\n\n{class_def}" if nested_classes_str else class_def return f"{nested_classes_str}\n\n{class_def}" if nested_classes_str else class_def
@@ -388,6 +400,12 @@ def run_gen(args: argparse.Namespace) -> None:
# ruff: noqa: F401 # ruff: noqa: F401
# fmt: off # fmt: off
from typing import Any, Literal, NotRequired, TypedDict\n from typing import Any, Literal, NotRequired, TypedDict\n
# Mimic "unknown".
# 'Any' is unsafe because it allows any operations
# This forces the user to use type-narrowing or casting in the code
class Unknown:
pass
""" """
) )
f.write(dataclass_code) f.write(dataclass_code)