Files
semantic-kernel/python/semantic_kernel/functions/kernel_plugin.py
T
Eduard van Valkenburg 9e99933a27 Python: new pre-commit actions and pre-commit as GHA (#6376)
### 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.
-->

- Added static code checking to pre-commit (check-ast and
nbqa-check-ast)
- Added Bandit security checking to pre-commit
- Added pre-commit step to python-lint workflow, if it works, can delete
seperate mypy, ruff and black tests

### 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 😄
2024-05-28 15:28:04 +00:00

545 lines
23 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import importlib
import inspect
import json
import logging
import os
from collections.abc import Generator, ItemsView
from functools import singledispatchmethod
from glob import glob
from types import MethodType
from typing import TYPE_CHECKING, Annotated, Any
import httpx
from pydantic import Field, StringConstraints
from semantic_kernel.connectors.openai_plugin.openai_authentication_config import OpenAIAuthenticationConfig
from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import (
OpenAIFunctionExecutionParameters,
)
from semantic_kernel.connectors.openai_plugin.openai_utils import OpenAIUtils
from semantic_kernel.connectors.openapi_plugin.openapi_manager import create_functions_from_openapi
from semantic_kernel.connectors.utils.document_loader import DocumentLoader
from semantic_kernel.exceptions import PluginInitializationError
from semantic_kernel.exceptions.function_exceptions import FunctionInitializationError
from semantic_kernel.functions.kernel_function import KernelFunction
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
from semantic_kernel.functions.kernel_function_from_prompt import KernelFunctionFromPrompt
from semantic_kernel.functions.types import KERNEL_FUNCTION_TYPE
from semantic_kernel.kernel_pydantic import KernelBaseModel
from semantic_kernel.utils.validation import PLUGIN_NAME_REGEX
if TYPE_CHECKING:
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
OpenAPIFunctionExecutionParameters,
)
from semantic_kernel.functions.kernel_function_metadata import KernelFunctionMetadata
logger = logging.getLogger(__name__)
class KernelPlugin(KernelBaseModel):
"""Represents a Kernel Plugin with functions.
This class behaves mostly like a dictionary, with functions as values and their names as keys.
When you add a function, through `.set` or `__setitem__`, the function is copied, the metadata is deep-copied
and the name of the plugin is set in the metadata and added to the dict of functions.
This is done in the same way as a normal dict, so a existing key will be overwritten.
Attributes:
name (str): The name of the plugin. The name can be upper/lower
case letters and underscores.
description (str): The description of the plugin.
functions (Dict[str, KernelFunction]): The functions in the plugin,
indexed by their name.
Methods:
set: Set a function in the plugin.
__setitem__: Set a function in the plugin.
get: Get a function from the plugin.
__getitem__: Get a function from the plugin.
__contains__: Check if a function is in the plugin.
__iter__: Iterate over the functions in the plugin.
update: Update the plugin with the functions from another.
setdefault: Set a default value for a key.
get_functions_metadata: Get the metadata for the functions in the plugin.
Class methods:
from_object(plugin_name: str, plugin_instance: Any | dict[str, Any], description: str | None = None):
Create a plugin from a existing object, like a custom class with annotated functions.
from_directory(plugin_name: str, parent_directory: str, description: str | None = None):
Create a plugin from a directory, parsing:
.py files, .yaml files and directories with skprompt.txt and config.json files.
from_openapi(
plugin_name: str,
openapi_document_path: str,
execution_settings: OpenAPIFunctionExecutionParameters | None = None,
description: str | None = None):
Create a plugin from an OpenAPI document.
from_openai(
plugin_name: str,
plugin_url: str | None = None,
plugin_str: str | None = None,
execution_parameters: OpenAIFunctionExecutionParameters | None = None,
description: str | None = None):
Create a plugin from the Open AI manifest.
"""
name: Annotated[str, StringConstraints(pattern=PLUGIN_NAME_REGEX, min_length=1)]
description: str | None = None
functions: dict[str, KernelFunction] = Field(default_factory=dict)
def __init__(
self,
name: str,
description: str | None = None,
functions: (
KERNEL_FUNCTION_TYPE
| "KernelPlugin"
| list[KERNEL_FUNCTION_TYPE | "KernelPlugin"]
| dict[str, KERNEL_FUNCTION_TYPE]
| None
) = None,
):
"""Create a KernelPlugin.
Args:
name: The name of the plugin. The name can be upper/lower
case letters and underscores.
description: The description of the plugin.
functions:
The functions in the plugin, will be rewritten to a dictionary of functions.
Raises:
ValueError: If the functions are not of the correct type.
PydanticError: If the name is not a valid plugin name.
"""
super().__init__(
name=name,
description=description,
functions=self._validate_functions(functions=functions, plugin_name=name),
)
# region Dict-like methods
def __setitem__(self, key: str, value: KERNEL_FUNCTION_TYPE) -> None:
"""Sets a function in the plugin.
This function uses plugin[function_name] = function syntax.
Args:
key (str): The name of the function.
value (KernelFunction): The function to set.
"""
self.functions[key] = KernelPlugin._parse_or_copy(value, self.name)
def set(self, key: str, value: KERNEL_FUNCTION_TYPE) -> None:
"""Set a function in the plugin.
This function uses plugin.set(function_name, function) syntax.
Args:
key (str): The name of the function.
value (KernelFunction): The function to set.
"""
self[key] = value
def __getitem__(self, key: str) -> KernelFunction:
"""Get a function from the plugin.
Using plugin[function_name] syntax.
"""
return self.functions[key]
def get(self, key: str, default: KernelFunction | None = None) -> KernelFunction | None:
"""Get a function from the plugin.
Args:
key (str): The name of the function.
default (KernelFunction, optional): The default function to return if the key is not found.
"""
return self.functions.get(key, default)
def update(self, *args: Any, **kwargs: KernelFunction) -> None:
"""Update the plugin with the functions from another.
Args:
*args: The functions to update the plugin with, can be a dict, list or KernelPlugin.
**kwargs: The kernel functions to update the plugin with.
"""
if len(args) > 1:
raise TypeError("update expected at most 1 arguments, got %d" % len(args))
if args:
if isinstance(args[0], KernelPlugin):
self.add(args[0].functions)
else:
self.add(args[0])
self.add(kwargs)
@singledispatchmethod
def add(self, functions: Any) -> None:
"""Add functions to the plugin."""
raise TypeError(f"Unknown type being added, type was {type(functions)}")
@add.register(list)
def add_list(self, functions: list[KERNEL_FUNCTION_TYPE | "KernelPlugin"]) -> None:
"""Add a list of functions to the plugin."""
for function in functions:
if isinstance(function, KernelPlugin):
self.add(function.functions)
continue
function = KernelPlugin._parse_or_copy(function, self.name)
self[function.name] = function
@add.register(dict)
def add_dict(self, functions: dict[str, KERNEL_FUNCTION_TYPE]) -> None:
"""Add a dictionary of functions to the plugin."""
for name, function in functions.items():
self[name] = function
def setdefault(self, key: str, value: KernelFunction | None = None):
"""Set a default value for a key."""
if key not in self.functions:
if value is None:
raise ValueError("Value must be provided for new key.")
self[key] = value
return self[key]
def __iter__(self) -> Generator[KernelFunction, None, None]: # type: ignore
"""Iterate over the functions in the plugin."""
yield from self.functions.values()
def __contains__(self, key: str) -> bool:
"""Check if a function is in the plugin."""
return key in self.functions
# endregion
# region Properties
def get_functions_metadata(self) -> list["KernelFunctionMetadata"]:
"""Get the metadata for the functions in the plugin.
Returns:
A list of KernelFunctionMetadata instances.
"""
return [func.metadata for func in self]
# endregion
# region Class Methods
@classmethod
def from_object(
cls,
plugin_name: str,
plugin_instance: Any | dict[str, Any],
description: str | None = None,
) -> "KernelPlugin":
"""Creates a plugin that wraps the specified target object and imports it into the kernel's plugin collection.
Args:
plugin_name (str): The name of the plugin. Allows chars: upper, lower ASCII and underscores.
plugin_instance (Any | dict[str, Any]): The plugin instance. This can be a custom class or a
dictionary of classes that contains methods with the kernel_function decorator for one or
several methods. See `TextMemoryPlugin` as an example.
description (str | None): The description of the plugin.
Returns:
KernelPlugin: The imported plugin of type KernelPlugin.
"""
functions: list[KernelFunction] = []
candidates: list[tuple[str, MethodType]] | ItemsView[str, Any] = []
if isinstance(plugin_instance, dict):
candidates = plugin_instance.items()
else:
candidates = inspect.getmembers(plugin_instance, inspect.ismethod)
# Read every method from the plugin instance
functions = [
KernelFunctionFromMethod(method=candidate, plugin_name=plugin_name)
for _, candidate in candidates
if hasattr(candidate, "__kernel_function__")
]
return cls(name=plugin_name, description=description, functions=functions) # type: ignore
@classmethod
def from_directory(
cls,
plugin_name: str,
parent_directory: str,
description: str | None = None,
class_init_arguments: dict[str, dict[str, Any]] | None = None,
) -> "KernelPlugin":
"""Create a plugin from a specified directory.
This method does not recurse into subdirectories beyond one level deep from the specified plugin directory.
For YAML files, function names are extracted from the content of the YAML files themselves (the name property).
For directories, the function name is assumed to be the name of the directory. Each KernelFunction object is
initialized with data parsed from the associated files and added to a list of functions that are then assigned
to the created KernelPlugin object.
A .py file is parsed and a plugin created,
the functions within as then combined with any other functions found.
The python file needs to contain a class with one or more kernel_function decorated methods.
If this class has a `__init__` method, it will be called with the arguments provided in the
`class_init_arguments` dictionary, the key needs to be the same as the name of the class,
with the value being a dictionary of arguments to pass to the class (using kwargs).
Example:
Assuming a plugin directory structure as follows:
MyPlugins/
|--- pluginA.yaml
|--- pluginB.yaml
|--- native_function.py
|--- Directory1/
|--- skprompt.txt
|--- config.json
|--- Directory2/
|--- skprompt.txt
|--- config.json
Calling `KernelPlugin.from_directory("MyPlugins", "/path/to")` will create a KernelPlugin object named
"MyPlugins", containing KernelFunction objects for `pluginA.yaml`, `pluginB.yaml`,
`Directory1`, and `Directory2`, each initialized with their respective configurations.
And functions for anything within native_function.py.
Args:
plugin_name (str): The name of the plugin, this is the name of the directory within the parent directory
parent_directory (str): The parent directory path where the plugin directory resides
description (str | None): The description of the plugin
class_init_arguments (dict[str, dict[str, Any]] | None): The class initialization arguments
Returns:
KernelPlugin: The created plugin of type KernelPlugin.
Raises:
PluginInitializationError: If the plugin directory does not exist.
PluginInvalidNameError: If the plugin name is invalid.
"""
plugin_directory = os.path.abspath(os.path.join(parent_directory, plugin_name))
if not os.path.exists(plugin_directory):
raise PluginInitializationError(f"Plugin directory does not exist: {plugin_name}")
functions: list[KernelFunction] = []
for object in glob(os.path.join(plugin_directory, "*")):
logger.debug(f"Found object: {object}")
if os.path.isdir(object):
if os.path.basename(object).startswith("__"):
continue
try:
functions.append(KernelFunctionFromPrompt.from_directory(path=object))
except FunctionInitializationError:
logger.warning(f"Failed to create function from directory: {object}")
elif object.endswith(".yaml") or object.endswith(".yml"):
with open(object) as file:
try:
functions.append(KernelFunctionFromPrompt.from_yaml(file.read()))
except FunctionInitializationError:
logger.warning(f"Failed to create function from YAML file: {object}")
elif object.endswith(".py"):
try:
functions.extend(
cls.from_python_file(
plugin_name=plugin_name,
py_file=object,
description=description,
class_init_arguments=class_init_arguments,
)
)
except PluginInitializationError:
logger.warning(f"Failed to create function from Python file: {object}")
else:
logger.warning(f"Unknown file found: {object}")
if not functions:
raise PluginInitializationError(f"No functions found in folder: {parent_directory}/{plugin_name}")
return cls(name=plugin_name, description=description, functions=functions) # type: ignore
@classmethod
def from_openapi(
cls,
plugin_name: str,
openapi_document_path: str,
execution_settings: "OpenAPIFunctionExecutionParameters | None" = None,
description: str | None = None,
) -> "KernelPlugin":
"""Create a plugin from an OpenAPI document.
Args:
plugin_name (str): The name of the plugin
openapi_document_path (str): The path to the OpenAPI document
execution_settings (OpenAPIFunctionExecutionParameters | None): The execution parameters
description (str | None): The description of the plugin
Returns:
KernelPlugin: The created plugin
Raises:
PluginInitializationError: if the plugin URL or plugin JSON/YAML is not provided
"""
if not openapi_document_path:
raise PluginInitializationError("OpenAPI document path is required.")
return cls( # type: ignore
name=plugin_name,
description=description,
functions=create_functions_from_openapi( # type: ignore
plugin_name=plugin_name,
openapi_document_path=openapi_document_path,
execution_settings=execution_settings,
),
)
@classmethod
async def from_openai(
cls,
plugin_name: str,
plugin_url: str | None = None,
plugin_str: str | None = None,
execution_parameters: "OpenAIFunctionExecutionParameters | None" = None,
description: str | None = None,
) -> "KernelPlugin":
"""Create a plugin from the Open AI manifest.
Args:
plugin_name (str): The name of the plugin
plugin_url (str | None): The URL of the plugin
plugin_str (str | None): The JSON string of the plugin
execution_parameters (OpenAIFunctionExecutionParameters | None): The execution parameters
description (str | None): The description of the plugin
Returns:
KernelPlugin: The created plugin
Raises:
PluginInitializationError: if the plugin URL or plugin JSON/YAML is not provided
"""
if execution_parameters is None:
execution_parameters = OpenAIFunctionExecutionParameters()
if plugin_str is not None:
# Load plugin from the provided JSON string/YAML string
openai_manifest = plugin_str
elif plugin_url is not None:
# Load plugin from the URL
http_client = execution_parameters.http_client if execution_parameters.http_client else httpx.AsyncClient()
openai_manifest = await DocumentLoader.from_uri(
url=plugin_url, http_client=http_client, auth_callback=None, user_agent=execution_parameters.user_agent
)
else:
raise PluginInitializationError("Either plugin_url or plugin_json must be provided.")
try:
plugin_json = json.loads(openai_manifest)
except json.JSONDecodeError as ex:
raise PluginInitializationError("Parsing of Open AI manifest for auth config failed.") from ex
openai_auth_config = OpenAIAuthenticationConfig(**plugin_json["auth"])
openapi_spec_url = OpenAIUtils.parse_openai_manifest_for_openapi_spec_url(plugin_json=plugin_json)
# Modify the auth callback in execution parameters if it's provided
if execution_parameters and execution_parameters.auth_callback:
initial_auth_callback = execution_parameters.auth_callback
async def custom_auth_callback(**kwargs: Any):
return await initial_auth_callback(plugin_name, openai_auth_config, **kwargs) # pragma: no cover
execution_parameters.auth_callback = custom_auth_callback
return cls(
name=plugin_name,
description=description,
functions=create_functions_from_openapi( # type: ignore
plugin_name=plugin_name,
openapi_document_path=openapi_spec_url,
execution_settings=execution_parameters,
),
)
@classmethod
def from_python_file(
cls,
plugin_name: str,
py_file: str,
description: str | None = None,
class_init_arguments: dict[str, dict[str, Any]] | None = None,
) -> "KernelPlugin":
"""Create a plugin from a Python file."""
module_name = os.path.basename(py_file).replace(".py", "")
spec = importlib.util.spec_from_file_location(module_name, py_file)
if not spec:
raise PluginInitializationError(f"Could not load spec from file {py_file}")
module = importlib.util.module_from_spec(spec)
if not module or not spec.loader:
raise PluginInitializationError(f"No module found in file {py_file}")
spec.loader.exec_module(module)
for name, cls_instance in inspect.getmembers(module, inspect.isclass):
if cls_instance.__module__ != module_name:
continue
instance = getattr(module, name)(**class_init_arguments.get(name, {}) if class_init_arguments else {})
return cls.from_object(plugin_name=plugin_name, description=description, plugin_instance=instance)
raise PluginInitializationError(f"No class found in file: {py_file}")
# endregion
# region Internal Static Methods
@staticmethod
def _validate_functions(
functions: (
KERNEL_FUNCTION_TYPE
| list[KERNEL_FUNCTION_TYPE | "KernelPlugin"]
| dict[str, KERNEL_FUNCTION_TYPE]
| "KernelPlugin"
| None
),
plugin_name: str,
) -> dict[str, "KernelFunction"]:
"""Validates the functions and returns a dictionary of functions."""
if not functions or not plugin_name:
# if the plugin_name is not present, the validation will fail, so no point in parsing.
return {}
if isinstance(functions, dict):
return {
name: KernelPlugin._parse_or_copy(function=function, plugin_name=plugin_name)
for name, function in functions.items()
}
if isinstance(functions, KernelPlugin):
return {
name: function.function_copy(plugin_name=plugin_name) for name, function in functions.functions.items()
}
if isinstance(functions, KernelFunction):
return {functions.name: KernelPlugin._parse_or_copy(function=functions, plugin_name=plugin_name)}
if callable(functions):
function = KernelPlugin._parse_or_copy(function=functions, plugin_name=plugin_name)
return {function.name: function}
if isinstance(functions, list):
functions_dict: dict[str, KernelFunction] = {}
for function in functions: # type: ignore
if isinstance(function, KernelFunction) or callable(function):
function = KernelPlugin._parse_or_copy(function=function, plugin_name=plugin_name)
functions_dict[function.name] = function
elif isinstance(function, KernelPlugin): # type: ignore
functions_dict.update(
{
name: KernelPlugin._parse_or_copy(function=function, plugin_name=plugin_name)
for name, function in function.functions.items()
}
)
else:
raise ValueError(f"Invalid type for functions in list: {function} (type: {type(function)})")
return functions_dict
raise ValueError(f"Invalid type for supplied functions: {functions} (type: {type(functions)})")
@staticmethod
def _parse_or_copy(function: KERNEL_FUNCTION_TYPE, plugin_name: str) -> "KernelFunction":
"""Handle the function and return a KernelFunction instance."""
if isinstance(function, KernelFunction):
return function.function_copy(plugin_name=plugin_name)
if callable(function):
return KernelFunctionFromMethod(method=function, plugin_name=plugin_name)
raise ValueError(f"Invalid type for function: {function} (type: {type(function)})")
# endregion