From 1213608f3049fe1ff7e3f20a4e5a68619ba67f4a Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 18 Aug 2025 18:06:00 +0200 Subject: [PATCH 1/3] API: init support for narrowing union types This allows to relax constraints on functions using overloaded interfaces I.e. for unifying logic this allows passing 'callable | dict' Conretely useful for prompt values that are asked on demand in the cli, vs upfront in the ui --- pkgs/clan-cli/clan_lib/api/__init__.py | 4 ++- pkgs/clan-cli/clan_lib/api/util.py | 40 ++++++++++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/api/__init__.py b/pkgs/clan-cli/clan_lib/api/__init__.py index ffbbe0dac..1b1ed1f30 100644 --- a/pkgs/clan-cli/clan_lib/api/__init__.py +++ b/pkgs/clan-cli/clan_lib/api/__init__.py @@ -221,7 +221,9 @@ API.register(get_system_file) try: serialized_hints = { key: type_to_dict( - value, scope=name + " argument" if key != "return" else "return" + value, + scope=name + " argument" if key != "return" else "return", + narrow_unsupported_union_types=True, ) for key, value in hints.items() } diff --git a/pkgs/clan-cli/clan_lib/api/util.py b/pkgs/clan-cli/clan_lib/api/util.py index d4ac6a206..04d00a747 100644 --- a/pkgs/clan-cli/clan_lib/api/util.py +++ b/pkgs/clan-cli/clan_lib/api/util.py @@ -104,7 +104,10 @@ def is_total(typed_dict_class: type) -> bool: def type_to_dict( - t: Any, scope: str = "", type_map: dict[TypeVar, type] | None = None + t: Any, + scope: str = "", + type_map: dict[TypeVar, type] | None = None, + narrow_unsupported_union_types: bool = False, ) -> dict: if type_map is None: type_map = {} @@ -164,6 +167,8 @@ def type_to_dict( dict_properties: dict = {} dict_required: list[str] = [] for field_name, field_type in dict_fields.items(): + # Unwrap special case for "NotRequired" and "Required" + # A field type that only exist for TypedDicts if ( not is_type_in_union(field_type, type(None)) and get_origin(field_type) is not NotRequired @@ -181,9 +186,32 @@ def type_to_dict( "additionalProperties": False, } - if type(t) is UnionType: + origin = get_origin(t) + # UnionTypes + if type(t) is UnionType or origin is Union: + supported = [] + for arg in get_args(t): + try: + supported.append( + type_to_dict(arg, scope, type_map, narrow_unsupported_union_types) + ) + except JSchemaTypeError: + if narrow_unsupported_union_types: + # If we are narrowing unsupported union types, we skip the error + continue + raise + + if len(supported) == 0: + msg = f"{scope} - No supported types in Union {t!s}, type_map: {type_map}" + raise JSchemaTypeError(msg) + + if len(supported) == 1: + # If there's only one supported type, return it directly + return supported[0] + + # If there are multiple supported types, return them as oneOf return { - "oneOf": [type_to_dict(arg, scope, type_map) for arg in t.__args__], + "oneOf": supported, } if isinstance(t, TypeVar): @@ -221,12 +249,6 @@ def type_to_dict( schema = type_to_dict(base_type, scope) # Generate schema for the base type return apply_annotations(schema, metadata) - if origin is Union: - union_types = [type_to_dict(arg, scope, type_map) for arg in t.__args__] - return { - "oneOf": union_types, - } - if origin in {list, set, frozenset, tuple}: return { "type": "array", From 287a30348497c926a428dbf2ca6bd3f4c1d6eaa7 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 18 Aug 2025 19:29:54 +0200 Subject: [PATCH 2/3] API/schema: make type conversion more strict in terms of undefined fields --- .../src/workflows/Install/Install.stories.tsx | 7 + .../Install/steps/createInstaller.tsx | 6 +- .../workflows/Install/steps/installSteps.tsx | 6 +- .../clan_lib/api/type_to_jsonschema_test.py | 331 ++++++++++++++++++ pkgs/clan-cli/clan_lib/api/util.py | 22 +- 5 files changed, 356 insertions(+), 16 deletions(-) create mode 100644 pkgs/clan-cli/clan_lib/api/type_to_jsonschema_test.py diff --git a/pkgs/clan-app/ui/src/workflows/Install/Install.stories.tsx b/pkgs/clan-app/ui/src/workflows/Install/Install.stories.tsx index c7f739909..3ab6ed412 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/Install.stories.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/Install.stories.tsx @@ -64,6 +64,7 @@ const mockFetcher: Fetcher = ( description: "Name of the gritty", prompt_type: "line", display: { + helperText: null, label: "(1) Name", group: "User", required: true, @@ -74,6 +75,7 @@ const mockFetcher: Fetcher = ( description: "Name of the gritty", prompt_type: "line", display: { + helperText: null, label: "(2) Password", group: "Root", required: true, @@ -84,6 +86,7 @@ const mockFetcher: Fetcher = ( description: "Name of the gritty", prompt_type: "line", display: { + helperText: null, label: "(3) Gritty", group: "Root", required: true, @@ -99,6 +102,7 @@ const mockFetcher: Fetcher = ( description: "Name of the gritty", prompt_type: "line", display: { + helperText: null, label: "(4) Name", group: "User", required: true, @@ -109,6 +113,7 @@ const mockFetcher: Fetcher = ( description: "Name of the gritty", prompt_type: "line", display: { + helperText: null, label: "(5) Password", group: "Lonely", required: true, @@ -119,6 +124,7 @@ const mockFetcher: Fetcher = ( description: "Name of the gritty", prompt_type: "line", display: { + helperText: null, label: "(6) Batty", group: "Root", required: true, @@ -130,6 +136,7 @@ const mockFetcher: Fetcher = ( run_generators: null, get_machine_hardware_summary: { hardware_config: "nixos-facter", + platform: "x86_64-linux", }, }; diff --git a/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx b/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx index 50bea450d..a1fabfed7 100644 --- a/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx +++ b/pkgs/clan-app/ui/src/workflows/Install/steps/createInstaller.tsx @@ -324,9 +324,9 @@ const FlashProgress = () => { usb logo -
+
{