|
2 | 2 | import logging |
3 | 3 | from collections.abc import AsyncGenerator, Iterable |
4 | 4 | from contextlib import nullcontext |
| 5 | +from dataclasses import dataclass |
5 | 6 | from importlib import import_module |
6 | 7 | from typing import Annotated, Any |
7 | 8 | from unittest.mock import Mock, patch |
|
10 | 11 | from opentelemetry.trace import StatusCode |
11 | 12 |
|
12 | 13 | from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion |
13 | | -from semantic_kernel.exceptions import FunctionExecutionException, FunctionInitializationError |
| 14 | +from semantic_kernel.exceptions import FunctionExecutionException, FunctionInitializationError, KernelInvokeException |
14 | 15 | from semantic_kernel.filters.functions.function_invocation_context import FunctionInvocationContext |
15 | 16 | from semantic_kernel.filters.kernel_filters_extension import _rebuild_function_invocation_context |
16 | 17 | from semantic_kernel.functions.function_result import FunctionResult |
@@ -39,6 +40,30 @@ def __init__(self, id: str, name: str): |
39 | 40 | self.name = name |
40 | 41 |
|
41 | 42 |
|
| 43 | +@dataclass |
| 44 | +class CustomTypeDataclass: |
| 45 | + id: str |
| 46 | + name: str |
| 47 | + |
| 48 | + |
| 49 | +class DerivedCustomType(CustomTypeNonPydantic): |
| 50 | + pass |
| 51 | + |
| 52 | + |
| 53 | +@dataclass |
| 54 | +class DerivedDataclass(CustomTypeDataclass): |
| 55 | + pass |
| 56 | + |
| 57 | + |
| 58 | +@dataclass |
| 59 | +class SingleFieldDataclass: |
| 60 | + name: str |
| 61 | + |
| 62 | + |
| 63 | +class RevalidatedCustomType(CustomType): |
| 64 | + model_config = {"revalidate_instances": "always", "validate_assignment": False} |
| 65 | + |
| 66 | + |
42 | 67 | @pytest.fixture |
43 | 68 | def get_custom_type_function_pydantic(): |
44 | 69 | @kernel_function |
@@ -585,6 +610,89 @@ def func_default(base: str, input: str = "test"): |
585 | 610 | assert str(res) == "test" |
586 | 611 |
|
587 | 612 |
|
| 613 | +@pytest.mark.parametrize( |
| 614 | + ("param_type", "value"), |
| 615 | + [ |
| 616 | + (CustomTypeNonPydantic, CustomTypeNonPydantic("1", "Ada")), |
| 617 | + (CustomTypeDataclass, CustomTypeDataclass("1", "Ada")), |
| 618 | + (SingleFieldDataclass, SingleFieldDataclass("Ada")), |
| 619 | + (CustomTypeNonPydantic, DerivedCustomType("1", "Ada")), |
| 620 | + (CustomTypeDataclass, DerivedDataclass("1", "Ada")), |
| 621 | + ], |
| 622 | +) |
| 623 | +async def test_invoke_preserves_typed_instance(kernel, param_type, value): |
| 624 | + @kernel_function |
| 625 | + def identity(param: param_type) -> Any: |
| 626 | + return param |
| 627 | + |
| 628 | + func = kernel.add_function(plugin_name="test", function=identity) |
| 629 | + for _ in range(2): |
| 630 | + result = await kernel.invoke(func, arguments=KernelArguments(param=value)) |
| 631 | + assert result.value is value |
| 632 | + |
| 633 | + |
| 634 | +@pytest.mark.parametrize("input_kind", ["dicts", "instances", "mixed"]) |
| 635 | +async def test_invoke_list_of_typed_instances(kernel, input_kind): |
| 636 | + @kernel_function |
| 637 | + def identity(param: list[CustomTypeDataclass]) -> Any: |
| 638 | + return param |
| 639 | + |
| 640 | + values = [CustomTypeDataclass("1", "Ada"), CustomTypeDataclass("2", "Grace")] |
| 641 | + arguments = [ |
| 642 | + {"id": value.id, "name": value.name} |
| 643 | + if input_kind == "dicts" or (input_kind == "mixed" and index == 1) |
| 644 | + else value |
| 645 | + for index, value in enumerate(values) |
| 646 | + ] |
| 647 | + func = kernel.add_function(plugin_name="test", function=identity) |
| 648 | + result = await kernel.invoke(func, arguments=KernelArguments(param=arguments)) |
| 649 | + assert result.value == values |
| 650 | + if input_kind != "dicts": |
| 651 | + assert result.value[0] is values[0] |
| 652 | + |
| 653 | + |
| 654 | +async def test_invoke_typed_instance_returned_by_function(kernel): |
| 655 | + value = CustomTypeDataclass("1", "Ada") |
| 656 | + |
| 657 | + @kernel_function |
| 658 | + def produce() -> CustomTypeDataclass: |
| 659 | + return value |
| 660 | + |
| 661 | + @kernel_function |
| 662 | + def consume(param: CustomTypeDataclass) -> str: |
| 663 | + assert param is value |
| 664 | + return param.name |
| 665 | + |
| 666 | + producer = kernel.add_function(plugin_name="test", function=produce) |
| 667 | + consumer = kernel.add_function(plugin_name="test", function=consume) |
| 668 | + produced = await kernel.invoke(producer) |
| 669 | + result = await kernel.invoke(consumer, arguments=KernelArguments(param=produced.value)) |
| 670 | + assert result.value == "Ada" |
| 671 | + |
| 672 | + |
| 673 | +async def test_invoke_pydantic_instance_still_revalidated(kernel): |
| 674 | + @kernel_function |
| 675 | + def identity(param: RevalidatedCustomType) -> Any: |
| 676 | + return param |
| 677 | + |
| 678 | + func = kernel.add_function(plugin_name="test", function=identity) |
| 679 | + value = RevalidatedCustomType(id="1", name="Ada") |
| 680 | + result = await kernel.invoke(func, arguments=KernelArguments(param=value)) |
| 681 | + assert result.value == value |
| 682 | + assert result.value is not value |
| 683 | + |
| 684 | + value.id = 123 |
| 685 | + with pytest.raises(KernelInvokeException): |
| 686 | + await kernel.invoke(func, arguments=KernelArguments(param=value)) |
| 687 | + |
| 688 | + |
| 689 | +@pytest.mark.parametrize("value", [True, "7", 7]) |
| 690 | +def test_parse_integer_still_coerces_values(get_custom_type_function_nonpydantic, value): |
| 691 | + result = get_custom_type_function_nonpydantic._parse_parameter(value, int) |
| 692 | + assert type(result) is int |
| 693 | + assert result == int(value) |
| 694 | + |
| 695 | + |
588 | 696 | def test_parse_list_of_objects(get_custom_type_function_pydantic): |
589 | 697 | func = get_custom_type_function_pydantic |
590 | 698 |
|
|
0 commit comments