Files
kanchi 966fa8ca04 Python: Replace mutable default arguments and prevent unintended side effects (#11849)
### Motivation and Context

Some functions and methods were using mutable objects (typically `dict`)
as default argument values.
In Python, default arguments are evaluated only once at the time of
function definition.
This can lead to unexpected behavior when the default value is modified
within the function, as subsequent calls to the function may reuse the
modified object.

As a result, calling such functions or methods multiple times can lead
to unintended behavior.

Reference:  
[8.7. Function definitions (Official Python
Documentation)](https://docs.python.org/3.12/reference/compound_stmts.html#function-definitions)
> Default parameter values are evaluated from left to right when the
function definition is executed. This means that the expression is
evaluated once, when the function is defined, and that the same
“pre-computed” value is used for each call. This is especially important
to understand when a default parameter value is a mutable object, such
as a list or a dictionary: if the function modifies the object (e.g. by
appending an item to a list), the default parameter value is in effect
modified. This is generally not what was intended. A way around this is
to use None as the default, and explicitly test for it in the body of
the function, e.g.:


### Description

- Replaced mutable types (e.g., `dict`, `list`) used as default argument
values with `None`, and properly initialized them within the function
body.
- Reviewed and updated the parts of the code that performed destructive
modifications on the arguments, to avoid unintentionally modifying
variables from the caller’s scope.
- ~Added the `flake8-bugbear` rule in Ruff to detect this kind of
issue.~
- ~For now, errors in `tests` and `samples` directories are ignored due
to the large number of violations.~

### 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
- [ ] All unit tests pass, and I have added new tests where possible
- [ ] I didn't break anyone 😄
2025-05-06 19:03:05 +00:00

139 lines
6.0 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import MutableMapping, MutableSequence, Sequence
from typing import TypeVar
from pydantic import Field, field_validator, model_validator
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
from semantic_kernel.const import DEFAULT_SERVICE_NAME
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.prompt_template.const import KERNEL_TEMPLATE_FORMAT_NAME, TEMPLATE_FORMAT_TYPES
from semantic_kernel.prompt_template.input_variable import InputVariable
PromptExecutionSettingsT = TypeVar("PromptExecutionSettingsT", bound=PromptExecutionSettings)
logger: logging.Logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="PromptTemplateConfig")
class PromptTemplateConfig(KernelBaseModel):
"""Configuration for a prompt template.
Args:
name: The name of the prompt template.
description: The description of the prompt template.
template: The template for the prompt.
template_format: The format of the template, should be 'semantic-kernel', 'jinja2' or 'handlebars'.
input_variables: The input variables for the prompt.
allow_dangerously_set_content (bool = False): Allow content without encoding throughout, this overrides
the same settings in the prompt template config and input variables.
This reverts the behavior to unencoded input.
execution_settings: The execution settings for the prompt.
"""
name: str = ""
description: str | None = ""
template: str | None = None
template_format: TEMPLATE_FORMAT_TYPES = KERNEL_TEMPLATE_FORMAT_NAME
input_variables: MutableSequence[InputVariable] = Field(default_factory=list)
allow_dangerously_set_content: bool = False
execution_settings: MutableMapping[str, PromptExecutionSettings] = Field(default_factory=dict)
@model_validator(mode="after")
def check_input_variables(self):
"""Verify that input variable default values are string only."""
for variable in self.input_variables:
if variable.default and not isinstance(variable.default, str):
raise TypeError(f"Default value for input variable {variable.name} must be a string.")
return self
@field_validator("execution_settings", mode="before")
@classmethod
def rewrite_execution_settings(
cls: type[_T],
settings: PromptExecutionSettings
| Sequence[PromptExecutionSettings]
| MutableMapping[str, PromptExecutionSettings]
| None,
) -> MutableMapping[str, PromptExecutionSettings]:
"""Rewrite execution settings to a dictionary."""
if not settings:
return {}
if isinstance(settings, PromptExecutionSettings):
return {settings.service_id or DEFAULT_SERVICE_NAME: settings}
if isinstance(settings, Sequence):
return {s.service_id or DEFAULT_SERVICE_NAME: s for s in settings}
return settings
def add_execution_settings(self, settings: PromptExecutionSettings, overwrite: bool = True) -> None:
"""Add execution settings to the prompt template."""
if settings.service_id in self.execution_settings and not overwrite:
return
self.execution_settings[settings.service_id or DEFAULT_SERVICE_NAME] = settings
logger.warning("Execution settings already exist and overwrite is set to False")
def get_kernel_parameter_metadata(self) -> Sequence[KernelParameterMetadata]:
"""Get the kernel parameter metadata for the input variables."""
return [
KernelParameterMetadata(
name=variable.name,
description=variable.description,
default_value=variable.default,
type_=variable.json_schema, # TODO (moonbox3): update to handle complex JSON schemas # type: ignore
is_required=variable.is_required,
)
for variable in self.input_variables
]
@classmethod
def from_json(cls: type[_T], json_str: str) -> _T:
"""Create a PromptTemplateConfig instance from a JSON string."""
if not json_str:
raise ValueError("json_str is empty")
try:
return cls.model_validate_json(json_str)
except Exception as exc:
raise ValueError(
"Unable to deserialize PromptTemplateConfig from the "
f"specified JSON string: {json_str} with exception: {exc}"
) from exc
@classmethod
def restore(
cls: type[_T],
name: str,
description: str,
template: str,
template_format: TEMPLATE_FORMAT_TYPES = KERNEL_TEMPLATE_FORMAT_NAME,
input_variables: MutableSequence[InputVariable] | None = None,
execution_settings: MutableMapping[str, PromptExecutionSettings] | None = None,
allow_dangerously_set_content: bool = False,
) -> _T:
"""Restore a PromptTemplateConfig instance from the specified parameters.
Args:
name: The name of the prompt template.
description: The description of the prompt template.
template: The template for the prompt.
template_format: The format of the template, should be 'semantic-kernel', 'jinja2' or 'handlebars'.
input_variables: The input variables for the prompt.
execution_settings: The execution settings for the prompt.
allow_dangerously_set_content: Allow content without encoding.
Returns:
A new PromptTemplateConfig instance.
"""
return cls(
name=name,
description=description,
template=template,
template_format=template_format,
input_variables=input_variables or [],
execution_settings=execution_settings or {},
allow_dangerously_set_content=allow_dangerously_set_content,
)