Skip to content

Commit cbfd7e9

Browse files
authoredFeb 12, 2025
Python: improved content inits, added ndarray support for binary content and small fixes to defaults (microsoft#10469)
### 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. --> This PR adds support for ndarray's as the content carrier for all binary content types (binary, image, audio) as that is more optimized for larger content. It also does some fixes to the initialization of those content types and the underlying data uri type. Also some fixes for unspecified default param in pydantic Field, language servers do not recognize `Field("default value")` as having a default, so changed those occurances to `Field(default="default value")` ### 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 dc6ca1a commit cbfd7e9

24 files changed

+346
-153
lines changed
 

‎python/samples/learn_resources/plugins/GithubPlugin/github.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,22 @@
1212
class Repo(BaseModel):
1313
id: int = Field(..., alias="id")
1414
name: str = Field(..., alias="full_name")
15-
description: str | None = Field(None, alias="description")
15+
description: str | None = Field(default=None, alias="description")
1616
url: str = Field(..., alias="html_url")
1717

1818

1919
class User(BaseModel):
2020
id: int = Field(..., alias="id")
2121
login: str = Field(..., alias="login")
22-
name: str | None = Field(None, alias="name")
23-
company: str | None = Field(None, alias="company")
22+
name: str | None = Field(default=None, alias="name")
23+
company: str | None = Field(default=None, alias="company")
2424
url: str = Field(..., alias="html_url")
2525

2626

2727
class Label(BaseModel):
2828
id: int = Field(..., alias="id")
2929
name: str = Field(..., alias="name")
30-
description: str | None = Field(None, alias="description")
30+
description: str | None = Field(default=None, alias="description")
3131

3232

3333
class Issue(BaseModel):
@@ -37,12 +37,12 @@ class Issue(BaseModel):
3737
title: str = Field(..., alias="title")
3838
state: str = Field(..., alias="state")
3939
labels: list[Label] = Field(..., alias="labels")
40-
when_created: str | None = Field(None, alias="created_at")
41-
when_closed: str | None = Field(None, alias="closed_at")
40+
when_created: str | None = Field(default=None, alias="created_at")
41+
when_closed: str | None = Field(default=None, alias="closed_at")
4242

4343

4444
class IssueDetail(Issue):
45-
body: str | None = Field(None, alias="body")
45+
body: str | None = Field(default=None, alias="body")
4646

4747

4848
# endregion

‎python/semantic_kernel/agents/bedrock/models/bedrock_agent_model.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ class BedrockAgentModel(KernelBaseModel):
1717
# This model_config will merge with the KernelBaseModel.model_config
1818
model_config = ConfigDict(extra="allow")
1919

20-
agent_id: str | None = Field(None, alias="agentId", description="The unique identifier of the agent.")
21-
agent_name: str | None = Field(None, alias="agentName", description="The name of the agent.")
22-
agent_version: str | None = Field(None, alias="agentVersion", description="The version of the agent.")
23-
foundation_model: str | None = Field(None, alias="foundationModel", description="The foundation model.")
24-
agent_status: str | None = Field(None, alias="agentStatus", description="The status of the agent.")
20+
agent_id: str | None = Field(default=None, alias="agentId", description="The unique identifier of the agent.")
21+
agent_name: str | None = Field(default=None, alias="agentName", description="The name of the agent.")
22+
agent_version: str | None = Field(default=None, alias="agentVersion", description="The version of the agent.")
23+
foundation_model: str | None = Field(default=None, alias="foundationModel", description="The foundation model.")
24+
agent_status: str | None = Field(default=None, alias="agentStatus", description="The status of the agent.")

‎python/semantic_kernel/connectors/ai/open_ai/prompt_execution_settings/open_ai_audio_to_text_execution_settings.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
class OpenAIAudioToTextExecutionSettings(PromptExecutionSettings):
1414
"""Request settings for OpenAI audio to text services."""
1515

16-
ai_model_id: str | None = Field(None, serialization_alias="model")
16+
ai_model_id: str | None = Field(default=None, serialization_alias="model")
1717
filename: str | None = Field(
18-
None, description="Do not set this manually. It is set by the service based on the audio content."
18+
default=None,
19+
description="Do not set this manually. It is set by the service based on the audio content.",
1920
)
2021
language: str | None = None
2122
prompt: str | None = None

‎python/semantic_kernel/connectors/ai/open_ai/prompt_execution_settings/open_ai_text_to_image_execution_settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class OpenAITextToImageExecutionSettings(PromptExecutionSettings):
3636
"""Request settings for OpenAI text to image services."""
3737

3838
prompt: str | None = None
39-
ai_model_id: str | None = Field(None, serialization_alias="model")
39+
ai_model_id: str | None = Field(default=None, serialization_alias="model")
4040
size: ImageSize | None = None
4141
quality: str | None = None
4242
style: str | None = None

‎python/semantic_kernel/connectors/memory/azure_cosmosdb/azure_cosmosdb_settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class AzureCosmosDBSettings(KernelBaseSettings):
2121
env_prefix: ClassVar[str] = "COSMOSDB_"
2222

2323
api: str | None = None
24-
connection_string: SecretStr | None = Field(None, alias="AZCOSMOS_CONNSTR")
24+
connection_string: SecretStr | None = Field(default=None, alias="AZCOSMOS_CONNSTR")
2525

2626
model_config = ConfigDict(
2727
populate_by_name=True,

‎python/semantic_kernel/connectors/memory/postgres/postgres_settings.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ class PostgresSettings(KernelBaseSettings):
5959
env_prefix: ClassVar[str] = "POSTGRES_"
6060

6161
connection_string: SecretStr | None = None
62-
host: str | None = Field(None, alias=PGHOST_ENV_VAR)
63-
port: int | None = Field(5432, alias=PGPORT_ENV_VAR)
64-
dbname: str | None = Field(None, alias=PGDATABASE_ENV_VAR)
65-
user: str | None = Field(None, alias=PGUSER_ENV_VAR)
66-
password: SecretStr | None = Field(None, alias=PGPASSWORD_ENV_VAR)
67-
sslmode: str | None = Field(None, alias=PGSSL_MODE_ENV_VAR)
62+
host: str | None = Field(default=None, alias=PGHOST_ENV_VAR)
63+
port: int | None = Field(default=5432, alias=PGPORT_ENV_VAR)
64+
dbname: str | None = Field(default=None, alias=PGDATABASE_ENV_VAR)
65+
user: str | None = Field(default=None, alias=PGUSER_ENV_VAR)
66+
password: SecretStr | None = Field(default=None, alias=PGPASSWORD_ENV_VAR)
67+
sslmode: str | None = Field(default=None, alias=PGSSL_MODE_ENV_VAR)
6868

6969
min_pool: int = 1
7070
max_pool: int = 5

‎python/semantic_kernel/connectors/search/bing/bing_search_response.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ class BingWebPages(KernelBaseModel):
1414
"""The web pages from a Bing search."""
1515

1616
id: str | None = None
17-
some_results_removed: bool | None = Field(None, alias="someResultsRemoved")
18-
total_estimated_matches: int | None = Field(None, alias="totalEstimatedMatches")
19-
web_search_url: str | None = Field(None, alias="webSearchUrl")
17+
some_results_removed: bool | None = Field(default=None, alias="someResultsRemoved")
18+
total_estimated_matches: int | None = Field(default=None, alias="totalEstimatedMatches")
19+
web_search_url: str | None = Field(default=None, alias="webSearchUrl")
2020
value: list[BingWebPage] = Field(default_factory=list)
2121

2222

2323
@experimental_class
2424
class BingSearchResponse(KernelBaseModel):
2525
"""The response from a Bing search."""
2626

27-
type_: str = Field("", alias="_type")
27+
type_: str = Field(default="", alias="_type")
2828
query_context: dict[str, Any] = Field(default_factory=dict, validation_alias="queryContext")
29-
web_pages: BingWebPages | None = Field(None, alias="webPages")
29+
web_pages: BingWebPages | None = Field(default=None, alias="webPages")

‎python/semantic_kernel/connectors/search/google/google_search_result.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ class GoogleSearchResult(KernelBaseModel):
1414

1515
kind: str = ""
1616
title: str = ""
17-
html_title: str = Field("", alias="htmlTitle")
17+
html_title: str = Field(default="", alias="htmlTitle")
1818
link: str = ""
19-
display_link: str = Field("", alias="displayLink")
19+
display_link: str = Field(default="", alias="displayLink")
2020
snippet: str = ""
21-
html_snippet: str = Field("", alias="htmlSnippet")
22-
cache_id: str = Field("", alias="cacheId")
23-
formatted_url: str = Field("", alias="formattedUrl")
24-
html_formatted_url: str = Field("", alias="htmlFormattedUrl")
21+
html_snippet: str = Field(default="", alias="htmlSnippet")
22+
cache_id: str = Field(default="", alias="cacheId")
23+
formatted_url: str = Field(default="", alias="formattedUrl")
24+
html_formatted_url: str = Field(default="", alias="htmlFormattedUrl")
2525
pagemap: dict[str, Any] = Field(default_factory=dict)
2626
mime: str = ""
27-
file_format: str = Field("", alias="fileFormat")
27+
file_format: str = Field(default="", alias="fileFormat")
2828
image: dict[str, Any] = Field(default_factory=dict)
2929
labels: list[dict[str, Any]] = Field(default_factory=list)

‎python/semantic_kernel/contents/audio_content.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import mimetypes
44
from typing import Any, ClassVar, Literal, TypeVar
55

6+
from numpy import ndarray
67
from pydantic import Field
78

89
from semantic_kernel.contents.binary_content import BinaryContent
@@ -38,9 +39,43 @@ class AudioContent(BinaryContent):
3839
metadata (dict[str, Any]): Any metadata that should be attached to the response.
3940
"""
4041

41-
content_type: Literal[ContentTypes.AUDIO_CONTENT] = Field(AUDIO_CONTENT_TAG, init=False) # type: ignore
42+
content_type: Literal[ContentTypes.AUDIO_CONTENT] = Field(default=AUDIO_CONTENT_TAG, init=False) # type: ignore
4243
tag: ClassVar[str] = AUDIO_CONTENT_TAG
4344

45+
def __init__(
46+
self,
47+
uri: str | None = None,
48+
data_uri: str | None = None,
49+
data: str | bytes | ndarray | None = None,
50+
data_format: str | None = None,
51+
mime_type: str | None = None,
52+
**kwargs: Any,
53+
):
54+
"""Create an Audio Content object, either from a data_uri or data.
55+
56+
Args:
57+
uri: The reference uri of the content.
58+
data_uri: The data uri of the content.
59+
data: The data of the content.
60+
data_format: The format of the data (e.g. base64).
61+
mime_type: The mime type of the audio, only used with data.
62+
kwargs: Any additional arguments:
63+
inner_content: The inner content of the response,
64+
this should hold all the information from the response so even
65+
when not creating a subclass a developer
66+
can leverage the full thing.
67+
ai_model_id: The id of the AI model that generated this response.
68+
metadata: Any metadata that should be attached to the response.
69+
"""
70+
super().__init__(
71+
uri=uri,
72+
data_uri=data_uri,
73+
data=data,
74+
data_format=data_format,
75+
mime_type=mime_type,
76+
**kwargs,
77+
)
78+
4479
@classmethod
4580
def from_audio_file(cls: type[_T], path: str) -> "AudioContent":
4681
"""Create an instance from an audio file."""

‎python/semantic_kernel/contents/binary_content.py

+56-36
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22

33
import logging
44
import os
5+
from pathlib import Path
56
from typing import Annotated, Any, ClassVar, Literal, TypeVar
67
from xml.etree.ElementTree import Element # nosec
78

8-
from pydantic import Field, FilePath, UrlConstraints, computed_field
9+
from numpy import ndarray
10+
from pydantic import Field, FilePath, PrivateAttr, UrlConstraints, computed_field
911
from pydantic_core import Url
1012

1113
from semantic_kernel.contents.const import BINARY_CONTENT_TAG, ContentTypes
1214
from semantic_kernel.contents.kernel_content import KernelContent
1315
from semantic_kernel.contents.utils.data_uri import DataUri
14-
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
16+
from semantic_kernel.exceptions.content_exceptions import ContentException, ContentInitializationError
1517
from semantic_kernel.utils.experimental_decorator import experimental_class
1618

1719
logger = logging.getLogger(__name__)
@@ -38,56 +40,63 @@ class BinaryContent(KernelContent):
3840
3941
"""
4042

41-
content_type: Literal[ContentTypes.BINARY_CONTENT] = Field(BINARY_CONTENT_TAG, init=False) # type: ignore
43+
content_type: Literal[ContentTypes.BINARY_CONTENT] = Field(default=BINARY_CONTENT_TAG, init=False) # type: ignore
4244
uri: Url | str | None = None
45+
4346
default_mime_type: ClassVar[str] = "text/plain"
4447
tag: ClassVar[str] = BINARY_CONTENT_TAG
45-
_data_uri: DataUri | None = None
48+
_data_uri: DataUri | None = PrivateAttr(default=None)
4649

4750
def __init__(
4851
self,
4952
uri: Url | str | None = None,
5053
data_uri: DataUrl | str | None = None,
51-
data: str | bytes | None = None,
54+
data: str | bytes | ndarray | None = None,
5255
data_format: str | None = None,
5356
mime_type: str | None = None,
5457
**kwargs: Any,
5558
):
5659
"""Create a Binary Content object, either from a data_uri or data.
5760
5861
Args:
59-
uri (Url | str | None): The reference uri of the content.
60-
data_uri (DataUrl | None): The data uri of the content.
61-
data (str | bytes | None): The data of the content.
62-
data_format (str | None): The format of the data (e.g. base64).
63-
mime_type (str | None): The mime type of the image, only used with data.
64-
kwargs (Any): Any additional arguments:
65-
inner_content (Any): The inner content of the response,
62+
uri: The reference uri of the content.
63+
data_uri: The data uri of the content.
64+
data: The data of the content.
65+
data_format: The format of the data (e.g. base64).
66+
mime_type: The mime type of the content, not always relevant.
67+
kwargs: Any additional arguments:
68+
inner_content: The inner content of the response,
6669
this should hold all the information from the response so even
6770
when not creating a subclass a developer can leverage the full thing.
68-
ai_model_id (str | None): The id of the AI model that generated this response.
69-
metadata (dict[str, Any]): Any metadata that should be attached to the response.
71+
ai_model_id: The id of the AI model that generated this response.
72+
metadata: Any metadata that should be attached to the response.
7073
"""
71-
temp_data_uri = None
74+
temp_data_uri: DataUri | None = None
7275
if data_uri:
7376
temp_data_uri = DataUri.from_data_uri(data_uri, self.default_mime_type)
74-
if "metadata" in kwargs:
75-
kwargs["metadata"].update(temp_data_uri.parameters)
76-
else:
77-
kwargs["metadata"] = temp_data_uri.parameters
78-
elif data:
79-
if isinstance(data, str):
80-
temp_data_uri = DataUri(
81-
data_str=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
82-
)
83-
else:
84-
temp_data_uri = DataUri(
85-
data_bytes=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
86-
)
77+
kwargs.setdefault("metadata", {})
78+
kwargs["metadata"].update(temp_data_uri.parameters)
79+
elif data is not None:
80+
match data:
81+
case bytes():
82+
temp_data_uri = DataUri(
83+
data_bytes=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
84+
)
85+
case ndarray():
86+
temp_data_uri = DataUri(
87+
data_array=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
88+
)
89+
case str():
90+
temp_data_uri = DataUri(
91+
data_str=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
92+
)
8793

8894
if uri is not None:
8995
if isinstance(uri, str) and os.path.exists(uri):
90-
uri = str(FilePath(uri))
96+
if os.path.isfile(uri):
97+
uri = str(Path(uri))
98+
else:
99+
raise ContentInitializationError("URI must be a file path, not a directory.")
91100
elif isinstance(uri, str):
92101
uri = Url(uri)
93102

@@ -105,28 +114,36 @@ def data_uri(self) -> str:
105114
@data_uri.setter
106115
def data_uri(self, value: str):
107116
"""Set the data uri."""
108-
self._data_uri = DataUri.from_data_uri(value)
117+
if not self._data_uri:
118+
self._data_uri = DataUri.from_data_uri(value, self.default_mime_type)
119+
else:
120+
self._data_uri.update_data(value)
109121
self.metadata.update(self._data_uri.parameters)
110122

111123
@property
112124
def data(self) -> bytes:
113125
"""Get the data."""
126+
if self._data_uri and self._data_uri.data_array:
127+
return self._data_uri.data_array.tobytes()
114128
if self._data_uri and self._data_uri.data_bytes:
115129
return self._data_uri.data_bytes
116-
if self._data_uri and self._data_uri.data_str:
117-
return self._data_uri.data_str.encode("utf-8")
118130
return b""
119131

120132
@data.setter
121-
def data(self, value: str | bytes):
133+
def data(self, value: str | bytes | ndarray):
122134
"""Set the data."""
123135
if self._data_uri:
124136
self._data_uri.update_data(value)
125-
else:
126-
if isinstance(value, str):
137+
return
138+
match value:
139+
case ndarray():
140+
self._data_uri = DataUri(data_array=value, mime_type=self.mime_type)
141+
case str():
127142
self._data_uri = DataUri(data_str=value, mime_type=self.mime_type)
128-
else:
143+
case bytes():
129144
self._data_uri = DataUri(data_bytes=value, mime_type=self.mime_type)
145+
case _:
146+
raise ContentException("Data must be a string, bytes, or numpy array.")
130147

131148
@property
132149
def mime_type(self) -> str:
@@ -167,6 +184,9 @@ def from_element(cls: type[_T], element: Element) -> _T:
167184

168185
def write_to_file(self, path: str | FilePath) -> None:
169186
"""Write the data to a file."""
187+
if self._data_uri and self._data_uri.data_array is not None:
188+
self._data_uri.data_array.tofile(path)
189+
return
170190
with open(path, "wb") as file:
171191
file.write(self.data)
172192

‎python/semantic_kernel/contents/chat_message_content.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class ChatMessageContent(KernelContent):
8383
__str__: Returns the content of the response.
8484
"""
8585

86-
content_type: Literal[ContentTypes.CHAT_MESSAGE_CONTENT] = Field(CHAT_MESSAGE_CONTENT_TAG, init=False) # type: ignore
86+
content_type: Literal[ContentTypes.CHAT_MESSAGE_CONTENT] = Field(default=CHAT_MESSAGE_CONTENT_TAG, init=False) # type: ignore
8787
tag: ClassVar[str] = CHAT_MESSAGE_CONTENT_TAG
8888
role: AuthorRole
8989
name: str | None = None

0 commit comments

Comments
 (0)
Please sign in to comment.