Skip to content

Commit ba9af0e

Browse files
authoredMay 31, 2024
Python: mypy coverage enhancement (microsoft#6250)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> extend mypy coverage for prompt_templates, template_engine and some other smaller folders. still todo: connectors, memory and planner folders. ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄
1 parent 4508e8d commit ba9af0e

28 files changed

+183
-166
lines changed
 

‎python/mypy.ini

+6-24
Original file line numberDiff line numberDiff line change
@@ -15,34 +15,16 @@ no_implicit_reexport = true
1515

1616
[mypy-semantic_kernel.connectors.*]
1717
ignore_errors = true
18-
19-
[mypy-semantic_kernel.core_plugins.*]
20-
ignore_errors = true
21-
22-
[mypy-semantic_kernel.events.*]
23-
ignore_errors = true
18+
# TODO (eavanvalkenburg): remove this
19+
# https://github.com/microsoft/semantic-kernel/issues/6462
2420

2521
[mypy-semantic_kernel.memory.*]
2622
ignore_errors = true
23+
# TODO (eavanvalkenburg): remove this
24+
# https://github.com/microsoft/semantic-kernel/issues/6463
2725

2826
[mypy-semantic_kernel.planners.*]
2927
ignore_errors = true
30-
31-
[mypy-semantic_kernel.prompt_template.*]
32-
ignore_errors = true
33-
34-
[mypy-semantic_kernel.reliability.*]
35-
ignore_errors = true
36-
37-
[mypy-semantic_kernel.services.*]
38-
ignore_errors = true
39-
40-
[mypy-semantic_kernel.template_engine.*]
41-
ignore_errors = true
42-
43-
[mypy-semantic_kernel.text.*]
44-
ignore_errors = true
45-
46-
[mypy-semantic_kernel.utils.*]
47-
ignore_errors = true
28+
# TODO (eavanvalkenburg): remove this
29+
# https://github.com/microsoft/semantic-kernel/issues/6465
4830

‎python/semantic_kernel/const.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
2+
23
from typing import Final
34

45
METADATA_EXCEPTION_KEY: Final[str] = "exception"
6+
DEFAULT_SERVICE_NAME: Final[str] = "default"

‎python/semantic_kernel/core_plugins/conversation_summary_plugin.py

+18-10
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
# Copyright (c) Microsoft. All rights reserved.
22
from typing import TYPE_CHECKING, Annotated
33

4+
from semantic_kernel.functions.kernel_function_decorator import kernel_function
5+
46
if TYPE_CHECKING:
57
from semantic_kernel.functions.kernel_arguments import KernelArguments
8+
from semantic_kernel.functions.kernel_function import KernelFunction
69
from semantic_kernel.kernel import Kernel
710
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
811

912

1013
class ConversationSummaryPlugin:
1114
"""Semantic plugin that enables conversations summarization."""
1215

13-
from semantic_kernel.functions.kernel_function_decorator import kernel_function
14-
1516
# The max tokens to process in a single semantic function call.
1617
_max_tokens = 1024
1718

@@ -30,17 +31,21 @@ def __init__(
3031
) -> None:
3132
"""Initializes a new instance of the ConversationSummaryPlugin class.
3233
33-
:param kernel: The kernel instance.
34-
:param prompt_template_config: The prompt template configuration.
35-
:param return_key: The key to use for the return value.
34+
Args:
35+
kernel (Kernel): The kernel instance to use for the function.
36+
prompt_template_config (PromptTemplateConfig): The configuration to use for functions.
37+
return_key (str): The key to use for the return value.
3638
"""
3739
self.return_key = return_key
38-
self._summarizeConversationFunction = kernel.add_function(
40+
kernel.add_function(
3941
prompt=ConversationSummaryPlugin._summarize_conversation_prompt_template,
4042
plugin_name=ConversationSummaryPlugin.__name__,
4143
function_name="SummarizeConversation",
4244
prompt_template_config=prompt_template_config,
4345
)
46+
self._summarizeConversationFunction: "KernelFunction" = kernel.get_function(
47+
plugin_name=ConversationSummaryPlugin.__name__, function_name="SummarizeConversation"
48+
)
4449

4550
@kernel_function(
4651
description="Given a long conversation transcript, summarize the conversation.",
@@ -56,10 +61,13 @@ async def summarize_conversation(
5661
]:
5762
"""Given a long conversation transcript, summarize the conversation.
5863
59-
:param input: A long conversation transcript.
60-
:param kernel: The kernel for function execution.
61-
:param arguments: Arguments used by the kernel.
62-
:return: KernelArguments with the summarized conversation result in key self.return_key.
64+
Args:
65+
input (str): A long conversation transcript.
66+
kernel (Kernel): The kernel for function execution.
67+
arguments (KernelArguments): Arguments used by the kernel.
68+
69+
Returns:
70+
KernelArguments with the summarized conversation result in key self.return_key.
6371
"""
6472
from semantic_kernel.text import text_chunker
6573
from semantic_kernel.text.function_extension import aggregate_chunked_results

‎python/semantic_kernel/core_plugins/sessions_python_tool/sessions_python_plugin.py

+7-11
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ class SessionsPythonTool(KernelBaseModel):
3232
"""A plugin for running Python code in an Azure Container Apps dynamic sessions code interpreter."""
3333

3434
pool_management_endpoint: str
35-
settings: SessionsPythonSettings | None = None
35+
settings: SessionsPythonSettings
3636
auth_callback: Callable[..., Awaitable[Any]]
37-
http_client: httpx.AsyncClient | None = None
37+
http_client: httpx.AsyncClient
3838

3939
def __init__(
4040
self,
@@ -60,12 +60,10 @@ def __init__(
6060
logger.error(f"Failed to load the ACASessionsSettings with message: {e!s}")
6161
raise FunctionExecutionException(f"Failed to load the ACASessionsSettings with message: {e!s}") from e
6262

63-
endpoint = pool_management_endpoint or aca_settings.pool_management_endpoint
64-
6563
super().__init__(
66-
pool_management_endpoint=endpoint,
67-
auth_callback=auth_callback,
64+
pool_management_endpoint=aca_settings.pool_management_endpoint,
6865
settings=settings,
66+
auth_callback=auth_callback,
6967
http_client=http_client,
7068
**kwargs,
7169
)
@@ -100,7 +98,7 @@ def _sanitize_input(self, code: str) -> str:
10098
Remove whitespace, backtick & python (if llm mistakes python console as terminal).
10199
102100
Args:
103-
code: The query to sanitize
101+
code (str): The query to sanitize
104102
Returns:
105103
str: The sanitized query
106104
"""
@@ -201,7 +199,7 @@ async def upload_file(
201199
response = await self.http_client.post(
202200
url=f"{self.pool_management_endpoint}python/uploadFile?identifier={self.settings.session_id}",
203201
json={},
204-
files=files,
202+
files=files, # type: ignore
205203
)
206204

207205
response.raise_for_status()
@@ -232,9 +230,7 @@ async def list_files(self) -> list[SessionsRemoteFileMetadata]:
232230
response_json = response.json()
233231
return [SessionsRemoteFileMetadata.from_dict(entry) for entry in response_json["$values"]]
234232

235-
async def download_file(
236-
self, *, remote_file_path: str, local_file_path: str | None = None
237-
) -> BufferedReader | None:
233+
async def download_file(self, *, remote_file_path: str, local_file_path: str | None = None) -> BytesIO | None:
238234
"""Download a file from the session pool.
239235
240236
Args:

‎python/semantic_kernel/core_plugins/text_memory_plugin.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def recall(
6868
logger.warning(f"Memory not found in collection: {collection}")
6969
return ""
7070

71-
return results[0].text if limit == 1 else json.dumps([r.text for r in results])
71+
return results[0].text if limit == 1 else json.dumps([r.text for r in results]) # type: ignore
7272

7373
@kernel_function(
7474
description="Save information to semantic memory",

‎python/semantic_kernel/core_plugins/web_search_engine_plugin.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ def __init__(self, connector: "ConnectorBase") -> None:
3131
async def search(
3232
self,
3333
query: Annotated[str, "The search query"],
34-
num_results: Annotated[int | None, "The number of search results to return"] = 1,
35-
offset: Annotated[int | None, "The number of search results to skip"] = 0,
34+
num_results: Annotated[int, "The number of search results to return"] = 1,
35+
offset: Annotated[int, "The number of search results to skip"] = 0,
3636
) -> list[str]:
3737
"""Returns the search results of the query provided."""
3838
return await self._connector.search(query, num_results, offset)

‎python/semantic_kernel/functions/kernel_arguments.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import TYPE_CHECKING, Any
44

5+
from semantic_kernel.const import DEFAULT_SERVICE_NAME
6+
57
if TYPE_CHECKING:
68
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
79

@@ -35,7 +37,7 @@ def __init__(
3537
if isinstance(settings, dict):
3638
settings_dict = settings
3739
elif isinstance(settings, list):
38-
settings_dict = {s.service_id or "default": s for s in settings}
40+
settings_dict = {s.service_id or DEFAULT_SERVICE_NAME: s for s in settings}
3941
else:
40-
settings_dict = {settings.service_id or "default": settings}
42+
settings_dict = {settings.service_id or DEFAULT_SERVICE_NAME: settings}
4143
self.execution_settings: dict[str, "PromptExecutionSettings"] | None = settings_dict

‎python/semantic_kernel/functions/kernel_function_from_prompt.py

+14-6
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import os
55
from collections.abc import AsyncGenerator
66
from html import unescape
7-
from typing import Any
7+
from typing import TYPE_CHECKING, Any
88

99
import yaml
1010
from pydantic import Field, ValidationError, model_validator
1111

1212
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
1313
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
1414
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
15+
from semantic_kernel.const import DEFAULT_SERVICE_NAME
1516
from semantic_kernel.contents.chat_history import ChatHistory
1617
from semantic_kernel.contents.chat_message_content import ChatMessageContent
1718
from semantic_kernel.contents.text_content import TextContent
@@ -31,6 +32,9 @@
3132
from semantic_kernel.prompt_template.prompt_template_base import PromptTemplateBase
3233
from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig
3334

35+
if TYPE_CHECKING:
36+
from semantic_kernel.services.ai_service_client_base import AIServiceClientBase
37+
3438
logger: logging.Logger = logging.getLogger(__name__)
3539

3640
PROMPT_FILE_NAME = "skprompt.txt"
@@ -146,10 +150,12 @@ def rewrite_execution_settings(
146150
return data
147151
if isinstance(prompt_execution_settings, PromptExecutionSettings):
148152
data["prompt_execution_settings"] = {
149-
prompt_execution_settings.service_id or "default": prompt_execution_settings
153+
prompt_execution_settings.service_id or DEFAULT_SERVICE_NAME: prompt_execution_settings
150154
}
151155
if isinstance(prompt_execution_settings, list):
152-
data["prompt_execution_settings"] = {s.service_id or "default": s for s in prompt_execution_settings}
156+
data["prompt_execution_settings"] = {
157+
s.service_id or DEFAULT_SERVICE_NAME: s for s in prompt_execution_settings
158+
}
153159
return data
154160

155161
async def _invoke_internal(self, context: FunctionInvocationContext) -> None:
@@ -232,7 +238,6 @@ async def _invoke_internal_stream(self, context: FunctionInvocationContext) -> N
232238
async def _render_prompt(self, context: FunctionInvocationContext) -> PromptRenderingResult:
233239
"""Render the prompt and apply the prompt rendering filters."""
234240
self.update_arguments_with_defaults(context.arguments)
235-
service, execution_settings = context.kernel.select_ai_service(self, context.arguments)
236241

237242
_rebuild_prompt_render_context()
238243
prompt_render_context = PromptRenderContext(function=self, kernel=context.kernel, arguments=context.arguments)
@@ -245,10 +250,13 @@ async def _render_prompt(self, context: FunctionInvocationContext) -> PromptRend
245250

246251
if prompt_render_context.rendered_prompt is None:
247252
raise PromptRenderingException("Prompt rendering failed, no rendered prompt was returned.")
253+
selected_service: tuple["AIServiceClientBase", PromptExecutionSettings] = context.kernel.select_ai_service(
254+
function=self, arguments=context.arguments
255+
)
248256
return PromptRenderingResult(
249257
rendered_prompt=prompt_render_context.rendered_prompt,
250-
ai_service=service,
251-
execution_settings=execution_settings,
258+
ai_service=selected_service[0],
259+
execution_settings=selected_service[1],
252260
)
253261

254262
async def _inner_render_prompt(self, context: PromptRenderContext) -> None:

‎python/semantic_kernel/kernel.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from collections.abc import AsyncGenerator, AsyncIterable
55
from copy import copy
6-
from typing import TYPE_CHECKING, Any, Literal
6+
from typing import TYPE_CHECKING, Any, Literal, TypeVar
77

88
from semantic_kernel.const import METADATA_EXCEPTION_KEY
99
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
@@ -19,14 +19,17 @@
1919
from semantic_kernel.functions.kernel_function_extension import KernelFunctionExtension
2020
from semantic_kernel.functions.kernel_function_from_prompt import KernelFunctionFromPrompt
2121
from semantic_kernel.functions.kernel_plugin import KernelPlugin
22+
from semantic_kernel.kernel_types import AI_SERVICE_CLIENT_TYPE
2223
from semantic_kernel.prompt_template.const import KERNEL_TEMPLATE_FORMAT_NAME
2324
from semantic_kernel.reliability.kernel_reliability_extension import KernelReliabilityExtension
2425
from semantic_kernel.services.ai_service_selector import AIServiceSelector
25-
from semantic_kernel.services.kernel_services_extension import AI_SERVICE_CLIENT_TYPE, KernelServicesExtension
26+
from semantic_kernel.services.kernel_services_extension import KernelServicesExtension
2627

2728
if TYPE_CHECKING:
2829
from semantic_kernel.functions.kernel_function import KernelFunction
2930

31+
T = TypeVar("T")
32+
3033

3134
logger: logging.Logger = logging.getLogger(__name__)
3235

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from typing import TypeVar
4+
5+
from semantic_kernel.services.ai_service_client_base import AIServiceClientBase
6+
7+
AI_SERVICE_CLIENT_TYPE = TypeVar("AI_SERVICE_CLIENT_TYPE", bound=AIServiceClientBase)

‎python/semantic_kernel/planners/function_calling_stepwise_planner/function_calling_stepwise_planner.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from semantic_kernel.connectors.ai.open_ai.services.azure_chat_completion import AzureChatCompletion
1616
from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import OpenAIChatCompletion
1717
from semantic_kernel.connectors.ai.open_ai.services.utils import kernel_function_metadata_to_openai_tool_format
18+
from semantic_kernel.const import DEFAULT_SERVICE_NAME
1819
from semantic_kernel.contents.chat_history import ChatHistory
1920
from semantic_kernel.contents.function_call_content import FunctionCallContent
2021
from semantic_kernel.contents.function_result_content import FunctionResultContent
@@ -256,8 +257,8 @@ def _create_config_from_yaml(self, kernel: Kernel) -> "KernelFunction":
256257
"""
257258
data = yaml.safe_load(self.generate_plan_yaml)
258259
prompt_template_config = PromptTemplateConfig(**data)
259-
if "default" in prompt_template_config.execution_settings:
260-
settings = prompt_template_config.execution_settings.pop("default")
260+
if DEFAULT_SERVICE_NAME in prompt_template_config.execution_settings:
261+
settings = prompt_template_config.execution_settings.pop(DEFAULT_SERVICE_NAME)
261262
prompt_template_config.execution_settings[self.service_id] = settings
262263
return kernel.add_function(
263264
function_name="create_plan",

‎python/semantic_kernel/planners/sequential_planner/sequential_planner.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44

55
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
6-
from semantic_kernel.const import METADATA_EXCEPTION_KEY
6+
from semantic_kernel.const import DEFAULT_SERVICE_NAME, METADATA_EXCEPTION_KEY
77
from semantic_kernel.exceptions import PlannerCreatePlanError, PlannerException, PlannerInvalidGoalError
88
from semantic_kernel.functions.function_result import FunctionResult
99
from semantic_kernel.functions.kernel_arguments import KernelArguments
@@ -67,8 +67,8 @@ def _init_flow_function(self, prompt: str, service_id: str) -> "KernelFunction":
6767
prompt_template = prompt or read_file(PROMPT_TEMPLATE_FILE_PATH)
6868
if service_id in prompt_config.execution_settings:
6969
prompt_config.execution_settings[service_id].extension_data["max_tokens"] = self.config.max_tokens
70-
elif "default" in prompt_config.execution_settings:
71-
prompt_config.execution_settings["default"].extension_data["max_tokens"] = self.config.max_tokens
70+
elif DEFAULT_SERVICE_NAME in prompt_config.execution_settings:
71+
prompt_config.execution_settings[DEFAULT_SERVICE_NAME].extension_data["max_tokens"] = self.config.max_tokens
7272
else:
7373
prompt_config.execution_settings[service_id] = PromptExecutionSettings(
7474
service_id=service_id, max_tokens=self.config.max_tokens
@@ -79,9 +79,9 @@ def _init_flow_function(self, prompt: str, service_id: str) -> "KernelFunction":
7979
if (
8080
service_id
8181
and service_id not in prompt_config.execution_settings
82-
and "default" in prompt_config.execution_settings
82+
and DEFAULT_SERVICE_NAME in prompt_config.execution_settings
8383
):
84-
settings = prompt_config.execution_settings.pop("default")
84+
settings = prompt_config.execution_settings.pop(DEFAULT_SERVICE_NAME)
8585
prompt_config.execution_settings[service_id] = settings
8686

8787
return self._kernel.add_function(

‎python/semantic_kernel/prompt_template/jinja2_prompt_template.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pydantic import PrivateAttr, field_validator
1010

1111
from semantic_kernel.exceptions import Jinja2TemplateRenderException
12+
from semantic_kernel.exceptions.template_engine_exceptions import TemplateRenderException
1213
from semantic_kernel.functions.kernel_arguments import KernelArguments
1314
from semantic_kernel.prompt_template.const import JINJA2_TEMPLATE_FORMAT_NAME
1415
from semantic_kernel.prompt_template.prompt_template_base import PromptTemplateBase
@@ -47,7 +48,7 @@ class Jinja2PromptTemplate(PromptTemplateBase):
4748
Jinja2TemplateSyntaxError: If there is a syntax error in the Jinja2 template.
4849
"""
4950

50-
_env: ImmutableSandboxedEnvironment = PrivateAttr()
51+
_env: ImmutableSandboxedEnvironment | None = PrivateAttr()
5152

5253
@field_validator("prompt_template_config")
5354
@classmethod
@@ -101,6 +102,8 @@ async def render(self, kernel: "Kernel", arguments: Optional["KernelArguments"]
101102
}
102103
)
103104
try:
105+
if self.prompt_template_config.template is None:
106+
raise TemplateRenderException("Template is None")
104107
template = self._env.from_string(self.prompt_template_config.template, globals=helpers)
105108
return template.render(**arguments)
106109

‎python/semantic_kernel/prompt_template/kernel_prompt_template.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from semantic_kernel.prompt_template.input_variable import InputVariable
1313
from semantic_kernel.prompt_template.prompt_template_base import PromptTemplateBase
1414
from semantic_kernel.template_engine.blocks.block import Block
15-
from semantic_kernel.template_engine.blocks.block_types import BlockTypes
15+
from semantic_kernel.template_engine.blocks.code_block import CodeBlock
16+
from semantic_kernel.template_engine.blocks.named_arg_block import NamedArgBlock
17+
from semantic_kernel.template_engine.blocks.var_block import VarBlock
1618
from semantic_kernel.template_engine.template_tokenizer import TemplateTokenizer
1719

1820
if TYPE_CHECKING:
@@ -56,17 +58,17 @@ def model_post_init(self, __context: Any) -> None:
5658

5759
# Enumerate every block in the template, adding any variables that are referenced.
5860
for block in self._blocks:
59-
if block.type == BlockTypes.VARIABLE:
61+
if isinstance(block, VarBlock):
6062
# Add all variables from variable blocks, e.g. "{{$a}}".
6163
self._add_if_missing(block.name, seen)
6264
continue
63-
if block.type == BlockTypes.CODE:
65+
if isinstance(block, CodeBlock):
6466
for sub_block in block.tokens:
65-
if sub_block.type == BlockTypes.VARIABLE:
67+
if isinstance(sub_block, VarBlock):
6668
# Add all variables from code blocks, e.g. "{{p.bar $b}}".
6769
self._add_if_missing(sub_block.name, seen)
6870
continue
69-
if sub_block.type == BlockTypes.NAMED_ARG and sub_block.variable:
71+
if isinstance(sub_block, NamedArgBlock) and sub_block.variable:
7072
# Add all variables from named arguments, e.g. "{{p.bar b = $b}}".
7173
# represents a named argument for a function call.
7274
# For example, in the template {{ MyPlugin.MyFunction var1=$boo }}, var1=$boo
@@ -75,6 +77,8 @@ def model_post_init(self, __context: Any) -> None:
7577

7678
def _add_if_missing(self, variable_name: str, seen: set | None = None):
7779
# Convert variable_name to lower case to handle case-insensitivity
80+
if not seen:
81+
seen = set()
7882
if variable_name and variable_name.lower() not in seen:
7983
seen.add(variable_name.lower())
8084
self.prompt_template_config.input_variables.append(InputVariable(name=variable_name))

‎python/semantic_kernel/prompt_template/prompt_template_config.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pydantic import Field, field_validator, model_validator
66

77
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
8+
from semantic_kernel.const import DEFAULT_SERVICE_NAME
89
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
910
from semantic_kernel.kernel_pydantic import KernelBaseModel
1011
from semantic_kernel.prompt_template.const import KERNEL_TEMPLATE_FORMAT_NAME, TEMPLATE_FORMAT_TYPES
@@ -57,16 +58,16 @@ def rewrite_execution_settings(
5758
if not settings:
5859
return {}
5960
if isinstance(settings, PromptExecutionSettings):
60-
return {settings.service_id or "default": settings}
61+
return {settings.service_id or DEFAULT_SERVICE_NAME: settings}
6162
if isinstance(settings, list):
62-
return {s.service_id or "default": s for s in settings}
63+
return {s.service_id or DEFAULT_SERVICE_NAME: s for s in settings}
6364
return settings
6465

6566
def add_execution_settings(self, settings: PromptExecutionSettings, overwrite: bool = True) -> None:
6667
"""Add execution settings to the prompt template."""
6768
if settings.service_id in self.execution_settings and not overwrite:
6869
return
69-
self.execution_settings[settings.service_id or "default"] = settings
70+
self.execution_settings[settings.service_id or DEFAULT_SERVICE_NAME] = settings
7071
logger.warning("Execution settings already exist and overwrite is set to False")
7172

7273
def get_kernel_parameter_metadata(self) -> list[KernelParameterMetadata]:

‎python/semantic_kernel/prompt_template/utils/template_function_helpers.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
import logging
55
from collections.abc import Callable
66
from html import escape
7-
from typing import TYPE_CHECKING, Any, Literal
7+
from typing import TYPE_CHECKING, Any
88

99
import nest_asyncio
1010

1111
from semantic_kernel.functions.kernel_arguments import KernelArguments
12-
from semantic_kernel.prompt_template.const import HANDLEBARS_TEMPLATE_FORMAT_NAME
12+
from semantic_kernel.prompt_template.const import (
13+
HANDLEBARS_TEMPLATE_FORMAT_NAME,
14+
JINJA2_TEMPLATE_FORMAT_NAME,
15+
TEMPLATE_FORMAT_TYPES,
16+
)
1317

1418
if TYPE_CHECKING:
1519
from semantic_kernel.functions.kernel_function import KernelFunction
@@ -23,10 +27,13 @@ def create_template_helper_from_function(
2327
function: "KernelFunction",
2428
kernel: "Kernel",
2529
base_arguments: "KernelArguments",
26-
template_format: Literal["handlebars", "jinja2"],
30+
template_format: TEMPLATE_FORMAT_TYPES,
2731
allow_dangerously_set_content: bool = False,
2832
) -> Callable[..., Any]:
2933
"""Create a helper function for both the Handlebars and Jinja2 templating engines from a kernel function."""
34+
if template_format not in [JINJA2_TEMPLATE_FORMAT_NAME, HANDLEBARS_TEMPLATE_FORMAT_NAME]:
35+
raise ValueError(f"Invalid template format: {template_format}")
36+
3037
if not getattr(asyncio, "_nest_patched", False):
3138
nest_asyncio.apply()
3239

‎python/semantic_kernel/reliability/pass_through_without_retry.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def execute_with_retry(self, action: Callable[[], Awaitable[T]]) -> Awaita
2525
Awaitable[T]: An awaitable that will return the result of the action.
2626
"""
2727
try:
28-
await action()
28+
return action()
2929
except Exception as e:
3030
logger.warning(e, "Error executing action, not retrying")
3131
raise e

‎python/semantic_kernel/services/ai_service_client_base.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def model_post_init(self, __context: object | None = None):
2828
if not self.service_id:
2929
self.service_id = self.ai_model_id
3030

31-
def get_prompt_execution_settings_class(self) -> "PromptExecutionSettings":
31+
def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"]:
3232
"""Get the request settings class."""
3333
return PromptExecutionSettings # pragma: no cover
3434

‎python/semantic_kernel/services/ai_service_selector.py

+10-7
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
from typing import TYPE_CHECKING
44

5+
from semantic_kernel.const import DEFAULT_SERVICE_NAME
56
from semantic_kernel.exceptions import KernelServiceNotFoundError
7+
from semantic_kernel.kernel_types import AI_SERVICE_CLIENT_TYPE
68

79
if TYPE_CHECKING:
810
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
911
from semantic_kernel.functions.kernel_arguments import KernelArguments
1012
from semantic_kernel.functions.kernel_function import KernelFunction
11-
from semantic_kernel.kernel import AI_SERVICE_CLIENT_TYPE, Kernel
13+
from semantic_kernel.services.ai_service_client_base import AIServiceClientBase
14+
from semantic_kernel.services.kernel_services_extension import KernelServicesExtension
1215

1316

1417
class AIServiceSelector:
@@ -20,11 +23,11 @@ class AIServiceSelector:
2023

2124
def select_ai_service(
2225
self,
23-
kernel: "Kernel",
26+
kernel: "KernelServicesExtension",
2427
function: "KernelFunction",
2528
arguments: "KernelArguments",
26-
type_: type["AI_SERVICE_CLIENT_TYPE"] | None = None,
27-
) -> tuple["AI_SERVICE_CLIENT_TYPE", "PromptExecutionSettings"]:
29+
type_: type[AI_SERVICE_CLIENT_TYPE] | tuple[type[AI_SERVICE_CLIENT_TYPE], ...] | None = None,
30+
) -> tuple["AIServiceClientBase", "PromptExecutionSettings"]:
2831
"""Select an AI Service on a first come, first served basis.
2932
3033
Starts with execution settings in the arguments,
@@ -35,7 +38,7 @@ def select_ai_service(
3538
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
3639
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
3740

38-
type_ = (TextCompletionClientBase, ChatCompletionClientBase)
41+
type_ = (TextCompletionClientBase, ChatCompletionClientBase) # type: ignore
3942

4043
execution_settings_dict = arguments.execution_settings or {}
4144
if func_exec_settings := getattr(function, "prompt_execution_settings", None):
@@ -45,13 +48,13 @@ def select_ai_service(
4548
if not execution_settings_dict:
4649
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
4750

48-
execution_settings_dict = {"default": PromptExecutionSettings()}
51+
execution_settings_dict = {DEFAULT_SERVICE_NAME: PromptExecutionSettings()}
4952
for service_id, settings in execution_settings_dict.items():
5053
try:
5154
service = kernel.get_service(service_id, type=type_)
5255
except KernelServiceNotFoundError:
5356
continue
54-
if service:
57+
if service is not None:
5558
service_settings = service.get_prompt_execution_settings_from_settings(settings)
5659
return service, service_settings
5760
raise KernelServiceNotFoundError("No service found.")

‎python/semantic_kernel/services/kernel_services_extension.py

+39-48
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,22 @@
22

33
import logging
44
from abc import ABC
5-
from typing import TYPE_CHECKING, TypeVar, Union
5+
from typing import TYPE_CHECKING
66

77
from pydantic import Field, field_validator
88

99
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
10-
from semantic_kernel.exceptions import (
11-
KernelFunctionAlreadyExistsError,
12-
KernelServiceNotFoundError,
13-
ServiceInvalidTypeError,
14-
)
15-
from semantic_kernel.functions.kernel_arguments import KernelArguments
10+
from semantic_kernel.const import DEFAULT_SERVICE_NAME
11+
from semantic_kernel.exceptions import KernelFunctionAlreadyExistsError, KernelServiceNotFoundError
1612
from semantic_kernel.kernel_pydantic import KernelBaseModel
13+
from semantic_kernel.kernel_types import AI_SERVICE_CLIENT_TYPE
1714
from semantic_kernel.services.ai_service_client_base import AIServiceClientBase
1815
from semantic_kernel.services.ai_service_selector import AIServiceSelector
1916

2017
if TYPE_CHECKING:
21-
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
22-
from semantic_kernel.connectors.ai.embeddings.embedding_generator_base import EmbeddingGeneratorBase
23-
from semantic_kernel.connectors.ai.text_completion_client_base import TextCompletionClientBase
18+
from semantic_kernel.functions.kernel_arguments import KernelArguments
2419
from semantic_kernel.functions.kernel_function import KernelFunction
2520

26-
T = TypeVar("T")
27-
28-
AI_SERVICE_CLIENT_TYPE = TypeVar("AI_SERVICE_CLIENT_TYPE", bound=AIServiceClientBase)
29-
ALL_SERVICE_TYPES = Union["TextCompletionClientBase", "ChatCompletionClientBase", "EmbeddingGeneratorBase"]
30-
3121

3222
logger: logging.Logger = logging.getLogger(__name__)
3323

@@ -48,69 +38,70 @@ def rewrite_services(
4838
if not services:
4939
return {}
5040
if isinstance(services, AIServiceClientBase):
51-
return {services.service_id if services.service_id else "default": services} # type: ignore
41+
return {services.service_id if services.service_id else DEFAULT_SERVICE_NAME: services} # type: ignore
5242
if isinstance(services, list):
53-
return {s.service_id if s.service_id else "default": s for s in services}
43+
return {s.service_id if s.service_id else DEFAULT_SERVICE_NAME: s for s in services}
5444
return services
5545

5646
def select_ai_service(
57-
self, function: "KernelFunction", arguments: KernelArguments
58-
) -> tuple[ALL_SERVICE_TYPES, PromptExecutionSettings]:
47+
self, function: "KernelFunction", arguments: "KernelArguments"
48+
) -> tuple[AIServiceClientBase, PromptExecutionSettings]:
5949
"""Uses the AI service selector to select a service for the function."""
6050
return self.ai_service_selector.select_ai_service(self, function, arguments)
6151

6252
def get_service(
6353
self,
6454
service_id: str | None = None,
65-
type: type[ALL_SERVICE_TYPES] | None = None,
66-
) -> "AIServiceClientBase":
55+
type: type[AI_SERVICE_CLIENT_TYPE] | tuple[type[AI_SERVICE_CLIENT_TYPE], ...] | None = None,
56+
) -> AIServiceClientBase:
6757
"""Get a service by service_id and type.
6858
6959
Type is optional and when not supplied, no checks are done.
7060
Type should be
7161
TextCompletionClientBase, ChatCompletionClientBase, EmbeddingGeneratorBase
7262
or a subclass of one.
7363
You can also check for multiple types in one go,
74-
by using TextCompletionClientBase | ChatCompletionClientBase.
64+
by using a tuple: (TextCompletionClientBase, ChatCompletionClientBase).
7565
7666
If type and service_id are both None, the first service is returned.
7767
7868
Args:
7969
service_id (str | None): The service id,
8070
if None, the default service is returned or the first service is returned.
81-
type (Type[ALL_SERVICE_TYPES] | None): The type of the service, if None, no checks are done.
71+
type (Type[AI_SERVICE_CLIENT_TYPE] | tuple[type[AI_SERVICE_CLIENT_TYPE], ...] | None):
72+
The type of the service, if None, no checks are done on service type.
8273
8374
Returns:
84-
ALL_SERVICE_TYPES: The service.
75+
AIServiceClientBase: The service, should be a class derived from AIServiceClientBase.
8576
8677
Raises:
87-
ValueError: If no service is found that matches the type.
78+
KernelServiceNotFoundError: If no service is found that matches the type or id.
8879
8980
"""
90-
service: "AIServiceClientBase | None" = None
91-
if not service_id or service_id == "default":
92-
if not type:
93-
if default_service := self.services.get("default"):
94-
return default_service
95-
return next(iter(self.services.values()))
96-
if (default_service := self.services.get("default")) and isinstance(default_service, type):
97-
return default_service
98-
for service in self.services.values():
99-
if isinstance(service, type):
100-
return service
101-
raise KernelServiceNotFoundError(f"No service found of type {type}")
102-
if not (service := self.services.get(service_id)):
103-
raise KernelServiceNotFoundError(f"Service with service_id '{service_id}' does not exist")
104-
if type and not isinstance(service, type):
105-
raise ServiceInvalidTypeError(f"Service with service_id '{service_id}' is not of type {type}")
106-
return service
107-
108-
def get_services_by_type(self, type: type[ALL_SERVICE_TYPES]) -> dict[str, ALL_SERVICE_TYPES]:
81+
services = self.get_services_by_type(type)
82+
if not services:
83+
raise KernelServiceNotFoundError(f"No services found of type {type}.")
84+
if not service_id:
85+
service_id = DEFAULT_SERVICE_NAME
86+
87+
if service_id not in services:
88+
if service_id == DEFAULT_SERVICE_NAME:
89+
return next(iter(services.values()))
90+
raise KernelServiceNotFoundError(
91+
f"Service with service_id '{service_id}' does not exist or has a different type."
92+
)
93+
return services[service_id]
94+
95+
def get_services_by_type(
96+
self, type: type[AI_SERVICE_CLIENT_TYPE] | tuple[type[AI_SERVICE_CLIENT_TYPE], ...] | None
97+
) -> dict[str, AIServiceClientBase]:
10998
"""Get all services of a specific type."""
110-
return {service.service_id: service for service in self.services.values() if isinstance(service, type)} # type: ignore
99+
if type is None:
100+
return self.services
101+
return {service.service_id: service for service in self.services.values() if isinstance(service, type)}
111102

112103
def get_prompt_execution_settings_from_service_id(
113-
self, service_id: str, type: type[ALL_SERVICE_TYPES] | None = None
104+
self, service_id: str, type: type[AI_SERVICE_CLIENT_TYPE] | None = None
114105
) -> PromptExecutionSettings:
115106
"""Get the specific request settings from the service, instantiated with the service_id and ai_model_id."""
116107
service = self.get_service(service_id, type=type)
@@ -128,8 +119,8 @@ def add_service(self, service: AIServiceClientBase, overwrite: bool = False) ->
128119
"""
129120
if service.service_id not in self.services or overwrite:
130121
self.services[service.service_id] = service
131-
else:
132-
raise KernelFunctionAlreadyExistsError(f"Service with service_id '{service.service_id}' already exists")
122+
return
123+
raise KernelFunctionAlreadyExistsError(f"Service with service_id '{service.service_id}' already exists")
133124

134125
def remove_service(self, service_id: str) -> None:
135126
"""Delete a single service from the Kernel."""

‎python/semantic_kernel/template_engine/blocks/code_block.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
1212
from semantic_kernel.template_engine.blocks.block import Block
1313
from semantic_kernel.template_engine.blocks.block_types import BlockTypes
14+
from semantic_kernel.template_engine.blocks.function_id_block import FunctionIdBlock
15+
from semantic_kernel.template_engine.blocks.named_arg_block import NamedArgBlock
1416
from semantic_kernel.template_engine.code_tokenizer import CodeTokenizer
1517

1618
if TYPE_CHECKING:
@@ -107,13 +109,15 @@ async def render_code(self, kernel: "Kernel", arguments: "KernelArguments") -> s
107109
Otherwise, it is a value or variable and those are then rendered directly.
108110
"""
109111
logger.debug(f"Rendering code: `{self.content}`")
110-
if self.tokens[0].type == BlockTypes.FUNCTION_ID:
112+
if isinstance(self.tokens[0], FunctionIdBlock):
111113
return await self._render_function_call(kernel, arguments)
112114
# validated that if the first token is not a function_id, it is a value or variable
113-
return self.tokens[0].render(kernel, arguments)
115+
return self.tokens[0].render(kernel, arguments) # type: ignore
114116

115117
async def _render_function_call(self, kernel: "Kernel", arguments: "KernelArguments"):
116-
function_block = self.tokens[0]
118+
if not isinstance(self.tokens[0], FunctionIdBlock):
119+
raise CodeBlockRenderException("The first token should be a function_id")
120+
function_block: FunctionIdBlock = self.tokens[0]
117121
try:
118122
function = kernel.get_function(function_block.plugin_name, function_block.function_name)
119123
except (KernelFunctionNotFoundError, KernelPluginNotFoundError) as exc:
@@ -145,10 +149,10 @@ def _enrich_function_arguments(
145149
)
146150
for index, token in enumerate(self.tokens[1:], start=1):
147151
logger.debug(f"Parsing variable/value: `{self.tokens[1].content}`")
148-
rendered_value = token.render(kernel, arguments)
149-
if token.type != BlockTypes.NAMED_ARG and index == 1:
152+
rendered_value = token.render(kernel, arguments) # type: ignore
153+
if not isinstance(token, NamedArgBlock) and index == 1:
150154
arguments[function_metadata.parameters[0].name] = rendered_value
151155
continue
152-
arguments[token.name] = rendered_value
156+
arguments[token.name] = rendered_value # type: ignore
153157

154158
return arguments

‎python/semantic_kernel/template_engine/blocks/function_id_block.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
from re import compile
5-
from typing import TYPE_CHECKING, Any, ClassVar, Optional
5+
from typing import TYPE_CHECKING, Any, ClassVar
66

77
from pydantic import model_validator
88

@@ -39,7 +39,7 @@ class FunctionIdBlock(Block):
3939
"""
4040

4141
type: ClassVar[BlockTypes] = BlockTypes.FUNCTION_ID
42-
function_name: str | None = ""
42+
function_name: str = ""
4343
plugin_name: str | None = None
4444

4545
@model_validator(mode="before")
@@ -61,6 +61,6 @@ def parse_content(cls, fields: dict[str, Any]) -> dict[str, Any]:
6161
fields["function_name"] = matches.group("function")
6262
return fields
6363

64-
def render(self, *_: tuple["Kernel", Optional["KernelArguments"]]) -> str:
64+
def render(self, *_: "Kernel | KernelArguments | None") -> str:
6565
"""Render the function id block."""
6666
return self.content

‎python/semantic_kernel/template_engine/blocks/named_arg_block.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def parse_content(cls, fields: Any) -> Any:
9090
def render(self, kernel: "Kernel", arguments: Optional["KernelArguments"] = None) -> Any:
9191
"""Render the named argument block."""
9292
if self.value:
93-
return self.value.render(kernel, arguments)
93+
return self.value.render()
9494
if arguments is None:
9595
return ""
9696
if self.variable:

‎python/semantic_kernel/template_engine/blocks/val_block.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
from re import S, compile
5-
from typing import TYPE_CHECKING, Any, ClassVar, Optional
5+
from typing import TYPE_CHECKING, Any, ClassVar
66

77
from pydantic import model_validator
88

@@ -69,6 +69,6 @@ def parse_content(cls, fields: Any) -> Any:
6969
fields["quote"] = quote
7070
return fields
7171

72-
def render(self, *_: tuple["Kernel", Optional["KernelArguments"]]) -> str:
72+
def render(self, *_: "Kernel | KernelArguments | None") -> str:
7373
"""Render the value block."""
74-
return self.value
74+
return self.value or ""

‎python/semantic_kernel/template_engine/blocks/var_block.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class VarBlock(Block):
4545
"""
4646

4747
type: ClassVar[BlockTypes] = BlockTypes.VARIABLE
48-
name: str | None = ""
48+
name: str = ""
4949

5050
@model_validator(mode="before")
5151
@classmethod

‎python/semantic_kernel/text/text_chunker.py

+8-8
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
NEWLINE = os.linesep
1414

15-
TEXT_SPLIT_OPTIONS = [
15+
TEXT_SPLIT_OPTIONS: list[list[str] | None] = [
1616
["\n", "\r"],
1717
["."],
1818
["?", "!"],
@@ -25,7 +25,7 @@
2525
None,
2626
]
2727

28-
MD_SPLIT_OPTIONS = [
28+
MD_SPLIT_OPTIONS: list[list[str] | None] = [
2929
["."],
3030
["?", "!"],
3131
[";"],
@@ -112,8 +112,8 @@ def _split_text_paragraph(text: list[str], max_tokens: int, token_counter: Calla
112112
if not text:
113113
return []
114114

115-
paragraphs = []
116-
current_paragraph = []
115+
paragraphs: list[str] = []
116+
current_paragraph: list[str] = []
117117

118118
for line in text:
119119
num_tokens_line = token_counter(line)
@@ -187,7 +187,7 @@ def _split_text_lines(
187187
def _split_str_lines(
188188
text: str,
189189
max_tokens: int,
190-
separators: list[list[str]],
190+
separators: list[list[str] | None],
191191
trim: bool,
192192
token_counter: Callable = _token_counter,
193193
) -> list[str]:
@@ -196,7 +196,7 @@ def _split_str_lines(
196196
return []
197197

198198
text = text.replace("\r\n", "\n")
199-
lines = []
199+
lines: list[str] = []
200200
was_split = False
201201
for split_option in separators:
202202
if not lines:
@@ -224,7 +224,7 @@ def _split_str_lines(
224224
def _split_str(
225225
text: str,
226226
max_tokens: int,
227-
separators: list[str],
227+
separators: list[str] | None,
228228
trim: bool,
229229
token_counter: Callable = _token_counter,
230230
) -> tuple[list[str], bool]:
@@ -283,7 +283,7 @@ def _split_str(
283283
def _split_list(
284284
text: list[str],
285285
max_tokens: int,
286-
separators: list[str],
286+
separators: list[str] | None,
287287
trim: bool,
288288
token_counter: Callable = _token_counter,
289289
) -> tuple[list[str], bool]:

‎python/semantic_kernel/utils/experimental_decorator.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
import types
43
from collections.abc import Callable
54

65

76
def experimental_function(func: Callable) -> Callable:
87
"""Decorator to mark a function as experimental."""
9-
if isinstance(func, types.FunctionType):
8+
if callable(func):
109
if func.__doc__:
1110
func.__doc__ += "\n\nNote: This function is experimental and may change in the future."
1211
else:
1312
func.__doc__ = "Note: This function is experimental and may change in the future."
1413

15-
func.is_experimental = True
14+
setattr(func, "is_experimental", True)
1615

1716
return func
1817

@@ -25,6 +24,6 @@ def experimental_class(cls: type) -> type:
2524
else:
2625
cls.__doc__ = "Note: This class is experimental and may change in the future."
2726

28-
cls.is_experimental = True
27+
setattr(cls, "is_experimental", True)
2928

3029
return cls

‎python/tests/unit/kernel/test_kernel.py

+2-6
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,7 @@
1515
OpenAIFunctionExecutionParameters,
1616
)
1717
from semantic_kernel.const import METADATA_EXCEPTION_KEY
18-
from semantic_kernel.exceptions import (
19-
KernelFunctionAlreadyExistsError,
20-
KernelServiceNotFoundError,
21-
ServiceInvalidTypeError,
22-
)
18+
from semantic_kernel.exceptions import KernelFunctionAlreadyExistsError, KernelServiceNotFoundError
2319
from semantic_kernel.exceptions.kernel_exceptions import KernelFunctionNotFoundError, KernelPluginNotFoundError
2420
from semantic_kernel.exceptions.template_engine_exceptions import TemplateSyntaxError
2521
from semantic_kernel.functions.function_result import FunctionResult
@@ -472,7 +468,7 @@ def test_get_service_with_multiple_types_union(kernel_with_service: Kernel):
472468

473469

474470
def test_get_service_with_type_not_found(kernel_with_service: Kernel):
475-
with pytest.raises(ServiceInvalidTypeError):
471+
with pytest.raises(KernelServiceNotFoundError):
476472
kernel_with_service.get_service("service", type=ChatCompletionClientBase)
477473

478474

0 commit comments

Comments
 (0)
Please sign in to comment.