diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index 5ec519b5b377..f920e5745f11 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -3,7 +3,7 @@ import sys import types from enum import Enum -from typing import Any, Union, get_args, get_origin, get_type_hints +from typing import Any, ClassVar, Union, get_args, get_origin, get_type_hints from semantic_kernel.const import PARSED_ANNOTATION_UNION_DELIMITER from semantic_kernel.exceptions.function_exceptions import FunctionInvalidParameterConfiguration @@ -86,6 +86,11 @@ def build_model_schema( hints = get_type_hints(model, globalns=model_module_globals, localns={}) for field_name, field_type in hints.items(): + if field_type is ClassVar or get_origin(field_type) is ClassVar: + # ClassVar annotations are class-level constants: pydantic + # excludes them from model fields and serialized instances, + # so they must not become (required) schema properties. + continue field_description = None if hasattr(model, "model_fields") and field_name in model.model_fields: field_info = model.model_fields[field_name] diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index 5d24a599c96c..7f39cdca9786 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -2,7 +2,7 @@ import json from enum import Enum -from typing import Annotated, Any, Optional, Union +from typing import Annotated, Any, ClassVar, Optional, Union from unittest.mock import Mock import pytest @@ -455,3 +455,104 @@ def test_build_schema_with_nonpydantic_structured_output(): } assert structured_output_schema == expected_schema + + +class ModelWithClassVar(KernelBaseModel): + marker: ClassVar[str] = "constant" + value: int + + +class ModelWithBareClassVar(KernelBaseModel): + marker: ClassVar = 0 + value: str + + +class ModelInheritingClassVar(ModelWithClassVar): + extra: float + + +class PlainClassWithClassVar: + marker: ClassVar[dict] = {} + name: str + + +class ModelWithClassVarAndOptional(KernelBaseModel): + counter: ClassVar[int] = 5 + keep: str + drop: Optional[str] = None + + +def test_build_model_schema_excludes_class_var(): + schema = KernelJsonSchemaBuilder.build(ModelWithClassVar) + + assert schema == {"type": "object", "properties": {"value": {"type": "integer"}}, "required": ["value"]} + + +def test_build_model_schema_excludes_bare_class_var(): + schema = KernelJsonSchemaBuilder.build(ModelWithBareClassVar) + + assert schema == {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]} + + +def test_build_model_schema_excludes_inherited_class_var(): + schema = KernelJsonSchemaBuilder.build(ModelInheritingClassVar) + + assert schema == { + "type": "object", + "properties": {"value": {"type": "integer"}, "extra": {"type": "number"}}, + "required": ["value", "extra"], + } + + +def test_build_model_schema_excludes_class_var_on_plain_class(): + schema = KernelJsonSchemaBuilder.build(PlainClassWithClassVar) + + assert schema == {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} + + +def test_build_model_schema_class_var_with_optional_fields(): + schema = KernelJsonSchemaBuilder.build(ModelWithClassVarAndOptional) + + assert schema == { + "type": "object", + "properties": {"keep": {"type": "string"}, "drop": {"type": ["string", "null"]}}, + "required": ["keep"], + } + + +def test_build_model_schema_class_var_structured_output(): + schema = KernelJsonSchemaBuilder.build(ModelWithClassVar, structured_output=True) + + assert schema == { + "type": "object", + "properties": {"value": {"type": "integer"}}, + "required": ["value"], + "additionalProperties": False, + } + + +def test_build_model_schema_class_var_with_description(): + schema = KernelJsonSchemaBuilder.build(ModelWithClassVar, description="A payload") + + assert schema == { + "type": "object", + "properties": {"value": {"type": "integer"}}, + "required": ["value"], + "description": "A payload", + } + + +def test_build_model_schema_class_var_via_instance(): + instance = ModelWithClassVar(value=1) + + schema = KernelJsonSchemaBuilder.build(instance) + + assert schema == {"type": "object", "properties": {"value": {"type": "integer"}}, "required": ["value"]} + + +def test_build_model_schema_allows_serialized_payload(): + schema = KernelJsonSchemaBuilder.build(ModelWithClassVar) + + serialized = ModelWithClassVar(value=1).model_dump() + for field in schema.get("required", []): + assert field in serialized