From e25e3b4bf0f92ce9f8b26c24507928e95cac3fbb Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sat, 17 Oct 2026 11:43:55 -0700 Subject: [PATCH 1/7] Use Pydantic's alias_generator instead of a custom CamelCaseField All models now accept both camelCase and snake_case field names when loading from data. They always dump camelCase field names. File field_helpers.py is no longer needed, and is removed. This patch was generated by Claude; I skimmed it. --- CHANGELOG.md | 8 ++ src/typeagent/emails/email_message.py | 14 ++-- src/typeagent/knowpro/core_types.py | 26 +++--- src/typeagent/knowpro/dataclasses.py | 24 ++++-- src/typeagent/knowpro/field_helpers.py | 85 -------------------- src/typeagent/knowpro/index_protocols.py | 3 +- src/typeagent/knowpro/knowledge_schema.py | 21 +++-- src/typeagent/knowpro/search.py | 7 +- src/typeagent/knowpro/search_query_schema.py | 8 +- src/typeagent/knowpro/search_terms.py | 28 +++---- src/typeagent/knowpro/storage_protocols.py | 3 +- src/typeagent/knowpro/universal_message.py | 12 +-- tests/test_dataclasses.py | 77 ++++++++++++++++++ 13 files changed, 160 insertions(+), 156 deletions(-) delete mode 100644 src/typeagent/knowpro/field_helpers.py create mode 100644 tests/test_dataclasses.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bf8148ea..95a5fdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,14 @@ Code importing the renamed `knowpro.interfaces_*` names from the `typeagent.knowpro.interfaces` aggregator is unaffected. +#### Core typeagent package +- Replace `CamelCaseField` (and its module `typeagent.knowpro.field_helpers`) + with a pydantic alias generator: the `typeagent.knowpro.dataclasses.dataclass` + decorator now gives every field a camelCase alias. As a result, the + `source_id` field of messages and the `src_url` field of `EmailMessage` are + now saved as `sourceId` and `srcUrl`; data saved under the old names still + loads. + ### 0.5.0 (May 26) This release focuses on ingestion throughput, robustness, and developer tooling, diff --git a/src/typeagent/emails/email_message.py b/src/typeagent/emails/email_message.py index 176ea72f..bb802f87 100644 --- a/src/typeagent/emails/email_message.py +++ b/src/typeagent/emails/email_message.py @@ -5,9 +5,8 @@ from typing import Any from pydantic import Field -from pydantic.dataclasses import dataclass as pydantic_dataclass -from ..knowpro.field_helpers import CamelCaseField +from ..knowpro.dataclasses import dataclass as pydantic_dataclass from ..knowpro.interfaces import IKnowledgeSource, IMessage, IMessageMetadata from ..knowpro.knowledge_schema import Action, ConcreteEntity, Facet, KnowledgeResponse @@ -148,12 +147,13 @@ class EmailMessage(IMessage): def __init__(self, **data: Any) -> None: super().__init__(**data) - text_chunks: list[str] = CamelCaseField("The text chunks of the email message") - metadata: EmailMessageMeta = CamelCaseField( - "Metadata associated with the email message" + text_chunks: list[str] = Field(description="The text chunks of the email message") + # Without Field(), this would inherit the None default from IMessage. + metadata: EmailMessageMeta = Field( + description="Metadata associated with the email message" ) - tags: list[str] = CamelCaseField( - "Tags associated with the message", default_factory=list + tags: list[str] = Field( + default_factory=list, description="Tags associated with the message" ) timestamp: str | None = None # Use metadata.sent_on for the actual sent time src_url: str | None = None # Source file or uri for this email diff --git a/src/typeagent/knowpro/core_types.py b/src/typeagent/knowpro/core_types.py index a9612665..c0dafc0f 100644 --- a/src/typeagent/knowpro/core_types.py +++ b/src/typeagent/knowpro/core_types.py @@ -17,10 +17,10 @@ TypedDict, ) -from pydantic.dataclasses import dataclass +from pydantic import Field import typechat -from .field_helpers import CamelCaseField +from .dataclasses import dataclass from .knowledge_schema import Action, ConcreteEntity, KnowledgeResponse __all__ = [ @@ -144,10 +144,10 @@ class IMessage[TMetadata: IMessageMetadata](IKnowledgeSource, Protocol): @dataclass class ScoredSemanticRefOrdinal: - semantic_ref_ordinal: SemanticRefOrdinal = CamelCaseField( - "The ordinal of the semantic reference" + semantic_ref_ordinal: SemanticRefOrdinal = Field( + description="The ordinal of the semantic reference" ) - score: float = CamelCaseField("The relevance score") + score: float = Field(description="The relevance score") def __repr__(self) -> str: return f"{self.__class__.__name__}({self.semantic_ref_ordinal}, {self.score})" @@ -223,12 +223,12 @@ class TextLocationData(TypedDict): @dataclass(order=True) class TextLocation: # The ordinal of the message. - message_ordinal: MessageOrdinal = CamelCaseField("The ordinal of the message") + message_ordinal: MessageOrdinal = Field(description="The ordinal of the message") # The ordinal of the chunk. # In the end of a TextRange, 1 + ordinal of the last chunk in the range. - chunk_ordinal: int = CamelCaseField( - "The ordinal of the chunk; in the end of a TextRange, 1 + ordinal of the last chunk in the range", + chunk_ordinal: int = Field( default=0, + description="The ordinal of the chunk; in the end of a TextRange, 1 + ordinal of the last chunk in the range", ) def __repr__(self) -> str: @@ -320,12 +320,12 @@ class SemanticRefData(TypedDict): @dataclass class SemanticRef: - semantic_ref_ordinal: SemanticRefOrdinal = CamelCaseField( - "The ordinal of the semantic reference" + semantic_ref_ordinal: SemanticRefOrdinal = Field( + description="The ordinal of the semantic reference" ) - range: TextRange = CamelCaseField("The text range of the semantic reference") - knowledge: Knowledge = CamelCaseField( - "The knowledge associated with this semantic reference" + range: TextRange = Field(description="The text range of the semantic reference") + knowledge: Knowledge = Field( + description="The knowledge associated with this semantic reference" ) def __repr__(self) -> str: diff --git a/src/typeagent/knowpro/dataclasses.py b/src/typeagent/knowpro/dataclasses.py index 8eb4482d..44c4dd7f 100644 --- a/src/typeagent/knowpro/dataclasses.py +++ b/src/typeagent/knowpro/dataclasses.py @@ -1,15 +1,27 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Compatibility helpers for pydantic dataclasses.""" +"""Pydantic dataclass decorator that adds camelCase aliases to every field. + +Fields are declared in snake_case. On input, both the snake_case name and +its camelCase form are accepted; on output (``by_alias=True``), camelCase is +written. An explicit alias on a field takes precedence. +""" from collections.abc import Callable +import dataclasses from typing import Any, cast, overload from typing_extensions import dataclass_transform +from pydantic import AliasChoices, AliasGenerator, ConfigDict, Field +from pydantic.alias_generators import to_camel from pydantic.dataclasses import dataclass as _pydantic_dataclass -from .field_helpers import CamelCaseField +# AliasChoices rather than validate_by_name, which needs pydantic 2.11. +_ALIAS_GENERATOR = AliasGenerator( + validation_alias=lambda name: AliasChoices(name, to_camel(name)), + serialization_alias=to_camel, +) @overload @@ -20,14 +32,16 @@ def dataclass[T](__cls: type[T], /, **kwargs: Any) -> type[T]: ... def dataclass[T](**kwargs: Any) -> Callable[[type[T]], type[T]]: ... -@dataclass_transform(field_specifiers=(CamelCaseField,)) +@dataclass_transform(field_specifiers=(dataclasses.field, Field)) def dataclass[T]( __cls: type[T] | None = None, /, **kwargs: Any ) -> Callable[[type[T]], type[T]] | type[T]: - """Wrapper that preserves pydantic behavior while informing type-checkers.""" + """Like ``pydantic.dataclasses.dataclass``, plus camelCase aliases.""" + config = ConfigDict(alias_generator=_ALIAS_GENERATOR) + config.update(kwargs.pop("config", None) or {}) def wrap(cls: type[T]) -> type[T]: - return cast(type[T], _pydantic_dataclass(cls, **kwargs)) + return cast(type[T], _pydantic_dataclass(cls, config=config, **kwargs)) if __cls is None: return wrap diff --git a/src/typeagent/knowpro/field_helpers.py b/src/typeagent/knowpro/field_helpers.py deleted file mode 100644 index 99bb1acd..00000000 --- a/src/typeagent/knowpro/field_helpers.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -from dataclasses import MISSING -from typing import Any - -from pydantic import AliasChoices, Field -from pydantic.alias_generators import to_camel - - -def CamelCaseField( - description: str | None = None, - *, - field_name: str | None = None, - default: Any = MISSING, - default_factory: Any = MISSING, -) -> Any: - """ - Helper function to create a Field with camelCase serialization alias. - - Args: - description: The field description - field_name: The snake_case field name (if provided, creates Field directly) - default: The default value for the field (optional) - default_factory: The default factory for the field (optional) - - Returns: - If field_name is provided: A Field with serialization_alias set to the camelCase version - Otherwise: A descriptor that will create a Field with serialization_alias set to the camelCase version - of the field name and validation_alias set to accept both snake_case and camelCase versions. - - Note: For fields ending with underscore (like 'from_'), the underscore is removed in the camelCase version. - """ - - # If field_name is provided, create the Field directly - if field_name is not None: - clean_name = field_name.rstrip("_") - camel_name = to_camel(clean_name) - - field_kwargs = { - "description": description, - "serialization_alias": camel_name, - "validation_alias": AliasChoices(field_name, camel_name), - } - - if default is not MISSING: - field_kwargs["default"] = default - elif default_factory is not MISSING: - field_kwargs["default_factory"] = default_factory - - return Field(**field_kwargs) - - # Otherwise, use the descriptor approach for backward compatibility - class CamelCaseFieldDescriptor: - def __init__( - self, - description: str | None = None, - default: Any = MISSING, - default_factory: Any = MISSING, - ): - self.description = description - self.default = default - self.default_factory = default_factory - - def __set_name__(self, owner, name): - # Replace ourselves with the actual Field when the field name is known - # Handle trailing underscore (like from_ -> from) - clean_name = name.rstrip("_") - camel_name = to_camel(clean_name) - - field_kwargs = { - "description": self.description, - "serialization_alias": camel_name, - "validation_alias": AliasChoices(name, camel_name), - } - - if self.default is not MISSING: - field_kwargs["default"] = self.default - elif self.default_factory is not MISSING: - field_kwargs["default_factory"] = self.default_factory - - field = Field(**field_kwargs) - setattr(owner, name, field) - - return CamelCaseFieldDescriptor(description, default, default_factory) diff --git a/src/typeagent/knowpro/index_protocols.py b/src/typeagent/knowpro/index_protocols.py index cf4a2147..9101cab6 100644 --- a/src/typeagent/knowpro/index_protocols.py +++ b/src/typeagent/knowpro/index_protocols.py @@ -7,8 +7,6 @@ from collections.abc import Iterable, Sequence from typing import Protocol, runtime_checkable -from pydantic.dataclasses import dataclass - from ..aitools.embeddings import NormalizedEmbedding from .core_types import ( DateRange, @@ -20,6 +18,7 @@ Term, TextRange, ) +from .dataclasses import dataclass from .serialization_data import ( ConversationThreadData, MessageTextIndexData, diff --git a/src/typeagent/knowpro/knowledge_schema.py b/src/typeagent/knowpro/knowledge_schema.py index 99be6fdf..255eed9b 100644 --- a/src/typeagent/knowpro/knowledge_schema.py +++ b/src/typeagent/knowpro/knowledge_schema.py @@ -11,8 +11,9 @@ from typing_extensions import Doc +from pydantic import Field + from .dataclasses import dataclass -from .field_helpers import CamelCaseField @dataclass @@ -100,18 +101,15 @@ class Action: verbs: Annotated[list[str], Doc("Each verb is typically a word.")] # NOTE: Declared via Annotated so that there is no class-level default; # otherwise TypeChat would render this required field as optional. - verb_tense: Annotated[ - VerbTense, - CamelCaseField("The tense of the verb", field_name="verb_tense"), - ] - subject_entity_name: str | Literal["none"] = CamelCaseField( - "The name of the subject entity", default="none" + verb_tense: Annotated[VerbTense, Field(description="The tense of the verb")] + subject_entity_name: str | Literal["none"] = Field( + default="none", description="The name of the subject entity" ) - object_entity_name: str | Literal["none"] = CamelCaseField( - "The name of the object entity", default="none" + object_entity_name: str | Literal["none"] = Field( + default="none", description="The name of the object entity" ) - indirect_object_entity_name: str | Literal["none"] = CamelCaseField( - "The name of the indirect object entity", default="none" + indirect_object_entity_name: str | Literal["none"] = Field( + default="none", description="The name of the indirect object entity" ) params: list[str | ActionParam] | None = None subject_entity_facet: Annotated[ @@ -120,7 +118,6 @@ class Action: "If the action implies this additional facet or property of the subject entity, " "such as hobbies, activities, interests, personality" ), - CamelCaseField(field_name="subject_entity_facet"), ] = None diff --git a/src/typeagent/knowpro/search.py b/src/typeagent/knowpro/search.py index 0bca55be..6c3d738c 100644 --- a/src/typeagent/knowpro/search.py +++ b/src/typeagent/knowpro/search.py @@ -4,11 +4,12 @@ from collections.abc import Callable from typing import cast, TypeGuard +from pydantic import Field + from ..storage.memory import related_terms_index from ..storage.memory.message_index import IMessageTextEmbeddingIndex from .collections import MessageAccumulator, SemanticRefAccumulator from .dataclasses import dataclass -from .field_helpers import CamelCaseField from .interfaces import ( IConversation, IConversationSecondaryIndexes, @@ -71,8 +72,8 @@ @dataclass class SearchQueryExpr: - select_expressions: list[SearchSelectExpr] = CamelCaseField( - "List of selection expressions for the search" + select_expressions: list[SearchSelectExpr] = Field( + description="List of selection expressions for the search" ) raw_query: str | None = None diff --git a/src/typeagent/knowpro/search_query_schema.py b/src/typeagent/knowpro/search_query_schema.py index a3a5111f..26751cf0 100644 --- a/src/typeagent/knowpro/search_query_schema.py +++ b/src/typeagent/knowpro/search_query_schema.py @@ -9,7 +9,6 @@ from .dataclasses import dataclass from .date_time_schema import DateTimeRange -from .field_helpers import CamelCaseField @dataclass @@ -146,11 +145,7 @@ class SearchFilter: @dataclass class SearchExpr: - rewritten_query: Annotated[ - str, - Doc("The rewritten search query"), - CamelCaseField(field_name="rewritten_query"), - ] + rewritten_query: Annotated[str, Doc("The rewritten search query")] filters: Annotated[list[SearchFilter], Doc("List of search filters")] @@ -161,5 +156,4 @@ class SearchQuery: Doc( "One expression for each search required by user request. Each SearchExpr runs independently, so make them standalone by resolving references like 'it', 'that', 'them' etc." ), - CamelCaseField(field_name="search_expressions"), ] diff --git a/src/typeagent/knowpro/search_terms.py b/src/typeagent/knowpro/search_terms.py index 7675c382..102925af 100644 --- a/src/typeagent/knowpro/search_terms.py +++ b/src/typeagent/knowpro/search_terms.py @@ -6,7 +6,7 @@ from typing import Literal -from pydantic.dataclasses import dataclass +from pydantic import Field from .core_types import ( DateRange, @@ -15,7 +15,7 @@ Term, TextRange, ) -from .field_helpers import CamelCaseField +from .dataclasses import dataclass __all__ = [ "KnowledgePropertyName", @@ -43,9 +43,9 @@ class SearchTerm: """ term: Term - related_terms: list[Term] | None = CamelCaseField( - "Additional terms related to the term. These can be supplied from synonym tables and so on", + related_terms: list[Term] | None = Field( default=None, + description="Additional terms related to the term. These can be supplied from synonym tables and so on", ) @@ -81,21 +81,21 @@ class PropertySearchTerm: related terms secondary index, if one is available. """ - property_name: KnowledgePropertyName | SearchTerm = CamelCaseField( - "The property name to search for" + property_name: KnowledgePropertyName | SearchTerm = Field( + description="The property name to search for" ) - property_value: SearchTerm = CamelCaseField("The property value to search for") + property_value: SearchTerm = Field(description="The property value to search for") @dataclass class SearchTermGroup: """A group of search terms.""" - boolean_op: Literal["and", "or", "or_max"] = CamelCaseField( - "The boolean operation to apply to the terms" + boolean_op: Literal["and", "or", "or_max"] = Field( + description="The boolean operation to apply to the terms" ) - terms: list["SearchTermGroupTypes"] = CamelCaseField( - "The list of search terms in this group", default_factory=list + terms: list["SearchTermGroupTypes"] = Field( + default_factory=list, description="The list of search terms in this group" ) @@ -131,9 +131,9 @@ class WhenFilter: class SearchSelectExpr: """An expression used to select structured contents of a conversation.""" - search_term_group: SearchTermGroup = CamelCaseField( - "Term group that matches information" - ) # Term group that matches information + search_term_group: SearchTermGroup = Field( + description="Term group that matches information" + ) when: WhenFilter | None = None # Filter that scopes what information to match diff --git a/src/typeagent/knowpro/storage_protocols.py b/src/typeagent/knowpro/storage_protocols.py index ce223359..a4a242e3 100644 --- a/src/typeagent/knowpro/storage_protocols.py +++ b/src/typeagent/knowpro/storage_protocols.py @@ -8,8 +8,6 @@ from datetime import datetime as Datetime from typing import Any, NamedTuple, Protocol, Self -from pydantic.dataclasses import dataclass - from ..aitools.embeddings import NormalizedEmbedding from .core_types import ( IMessage, @@ -20,6 +18,7 @@ SemanticRefOrdinal, TextRange, ) +from .dataclasses import dataclass from .index_protocols import ( IConversationSecondaryIndexes, IConversationThreads, diff --git a/src/typeagent/knowpro/universal_message.py b/src/typeagent/knowpro/universal_message.py index 5e05568b..c6c85fb4 100644 --- a/src/typeagent/knowpro/universal_message.py +++ b/src/typeagent/knowpro/universal_message.py @@ -9,7 +9,6 @@ from pydantic import AliasChoices, Field from .dataclasses import dataclass as pydantic_dataclass -from .field_helpers import CamelCaseField from .interfaces import IKnowledgeSource, IMessage, IMessageMetadata from .knowledge_schema import Action, ConcreteEntity, KnowledgeResponse @@ -182,12 +181,13 @@ class ConversationMessageData(TypedDict): class ConversationMessage(IMessage): """Universal message for any conversation type.""" - text_chunks: list[str] = CamelCaseField("The text chunks of the message") - metadata: ConversationMessageMeta = CamelCaseField( - "Metadata associated with the message" + text_chunks: list[str] = Field(description="The text chunks of the message") + # Without Field(), this would inherit the None default from IMessage. + metadata: ConversationMessageMeta = Field( + description="Metadata associated with the message" ) - tags: list[str] = CamelCaseField( - "Tags associated with the message", default_factory=list + tags: list[str] = Field( + default_factory=list, description="Tags associated with the message" ) timestamp: str | None = None """ diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py new file mode 100644 index 00000000..6788f038 --- /dev/null +++ b/tests/test_dataclasses.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Tests for the camelCase-aliasing dataclass decorator.""" + +import pytest + +from pydantic import ConfigDict, ValidationError + +from typeagent.knowpro.dataclasses import dataclass +from typeagent.knowpro.universal_message import ( + ConversationMessage, + ConversationMessageMeta, +) + + +@dataclass +class Sample: + plain: int + two_words: str = "x" + + +def dump(obj: object) -> dict: + return obj.__pydantic_serializer__.to_python(obj, by_alias=True) # type: ignore[attr-defined] + + +def load[T](cls: type[T], data: dict) -> T: + return cls.__pydantic_validator__.validate_python(data) # type: ignore[attr-defined] + + +def test_constructor_uses_python_names() -> None: + assert Sample(1, two_words="y") == Sample(plain=1, two_words="y") + + +def test_output_is_camel_case() -> None: + assert dump(Sample(1, "y")) == {"plain": 1, "twoWords": "y"} + + +@pytest.mark.parametrize("key", ["two_words", "twoWords"]) +def test_input_accepts_both_spellings(key: str) -> None: + assert load(Sample, {"plain": 1, key: "y"}) == Sample(1, "y") + + +def test_caller_config_is_merged() -> None: + @dataclass(config=ConfigDict(extra="forbid")) + class Strict: + two_words: str + + assert dump(Strict("y")) == {"twoWords": "y"} + with pytest.raises(ValidationError): + load(Strict, {"twoWords": "y", "other": 1}) + + +def test_explicit_alias_wins() -> None: + meta = ConversationMessageMeta(speaker="a", recipients=["b"]) + data = dump(meta) + assert data["listeners"] == ["b"] + assert "recipients" not in data + assert load(ConversationMessageMeta, data) == meta + assert load(ConversationMessageMeta, {"speaker": "a", "recipients": ["b"]}) == meta + + +def test_message_metadata_is_required() -> None: + with pytest.raises(ValidationError): + load(ConversationMessage, {"textChunks": ["hi"]}) + + +def test_message_source_id_round_trip() -> None: + meta = ConversationMessageMeta(speaker="a") + msg = ConversationMessage(text_chunks=["hi"], metadata=meta, source_id="s1") + data = dump(msg) + assert data["sourceId"] == "s1" + assert "source_id" not in data + assert load(ConversationMessage, data) == msg + + # Data written before the alias generator used the Python name. + data["source_id"] = data.pop("sourceId") + assert load(ConversationMessage, data) == msg From 51e717bd1bcf5e7e8738667c86c8715122d518e9 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sat, 17 Oct 2026 12:00:51 -0700 Subject: [PATCH 2/7] Add tests to check that EmailMessage (de)serialization roundtrips --- tests/test_email_message.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_email_message.py b/tests/test_email_message.py index 17930486..52d9c9ff 100644 --- a/tests/test_email_message.py +++ b/tests/test_email_message.py @@ -221,3 +221,23 @@ def test_serialize_roundtrip(self) -> None: assert restored.text_chunks == msg.text_chunks assert restored.metadata.sender == msg.metadata.sender assert restored.tags == msg.tags + + def test_alias_migration_roundtrip(self) -> None: + msg = EmailMessage( + text_chunks=["Hello"], + metadata=make_meta(), + src_url="mailbox/inbox.mbox", + source_id="message-1", + ) + + data = msg.serialize() + assert data["srcUrl"] == "mailbox/inbox.mbox" + assert data["sourceId"] == "message-1" + assert "src_url" not in data + assert "source_id" not in data + + data["src_url"] = data.pop("srcUrl") + data["source_id"] = data.pop("sourceId") + restored = EmailMessage.deserialize(data) + assert restored.src_url == msg.src_url + assert restored.source_id == msg.source_id From f2992b8b1909b6c6590c3bf08873359924f84eb6 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sat, 17 Oct 2026 12:54:29 -0700 Subject: [PATCH 3/7] Change fields whose description wouldn't be rendered by TypeChat to use Doc() --- src/typeagent/knowpro/knowledge_schema.py | 22 ++++++++++------------ tests/test_knowledge_schema.py | 8 ++++++++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/typeagent/knowpro/knowledge_schema.py b/src/typeagent/knowpro/knowledge_schema.py index 255eed9b..a7ed6a6e 100644 --- a/src/typeagent/knowpro/knowledge_schema.py +++ b/src/typeagent/knowpro/knowledge_schema.py @@ -11,8 +11,6 @@ from typing_extensions import Doc -from pydantic import Field - from .dataclasses import dataclass @@ -101,16 +99,16 @@ class Action: verbs: Annotated[list[str], Doc("Each verb is typically a word.")] # NOTE: Declared via Annotated so that there is no class-level default; # otherwise TypeChat would render this required field as optional. - verb_tense: Annotated[VerbTense, Field(description="The tense of the verb")] - subject_entity_name: str | Literal["none"] = Field( - default="none", description="The name of the subject entity" - ) - object_entity_name: str | Literal["none"] = Field( - default="none", description="The name of the object entity" - ) - indirect_object_entity_name: str | Literal["none"] = Field( - default="none", description="The name of the indirect object entity" - ) + verb_tense: Annotated[VerbTense, Doc("The tense of the verb")] + subject_entity_name: Annotated[ + str | Literal["none"], Doc("The name of the subject entity") + ] = "none" + object_entity_name: Annotated[ + str | Literal["none"], Doc("The name of the object entity") + ] = "none" + indirect_object_entity_name: Annotated[ + str | Literal["none"], Doc("The name of the indirect object entity") + ] = "none" params: list[str | ActionParam] | None = None subject_entity_facet: Annotated[ Facet | None, diff --git a/tests/test_knowledge_schema.py b/tests/test_knowledge_schema.py index 2c5388f1..2bb7a58f 100644 --- a/tests/test_knowledge_schema.py +++ b/tests/test_knowledge_schema.py @@ -70,6 +70,14 @@ def test_action_verb_tense_is_rendered_required() -> None: assert "verb_tense: VerbTense;" in lines +def test_action_field_descriptions_are_rendered() -> None: + lines = [line.strip() for line in render_interface_body(Action).splitlines()] + assert "// The tense of the verb" in lines + assert "// The name of the subject entity" in lines + assert "// The name of the object entity" in lines + assert "// The name of the indirect object entity" in lines + + def test_action_verb_tense_is_required_by_validator() -> None: validator = typechat.TypeChatValidator[Action](Action) assert isinstance(validator.validate_object({"verbs": ["go"]}), typechat.Failure) From 76b96c0af216e3647b951545dc8dfea796fa37cd Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sun, 18 Oct 2026 16:05:34 -0700 Subject: [PATCH 4/7] Route message tests through serialization.py instead of Pydantic --- tests/test_dataclasses.py | 22 ++++++++++++++-------- tests/test_email_message.py | 6 ++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 6788f038..76a48576 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -6,6 +6,7 @@ from pydantic import ConfigDict, ValidationError +from typeagent.knowpro import serialization from typeagent.knowpro.dataclasses import dataclass from typeagent.knowpro.universal_message import ( ConversationMessage, @@ -52,26 +53,31 @@ class Strict: def test_explicit_alias_wins() -> None: meta = ConversationMessageMeta(speaker="a", recipients=["b"]) - data = dump(meta) + data = serialization.serialize_object(meta) assert data["listeners"] == ["b"] assert "recipients" not in data - assert load(ConversationMessageMeta, data) == meta - assert load(ConversationMessageMeta, {"speaker": "a", "recipients": ["b"]}) == meta + assert serialization.deserialize_object(ConversationMessageMeta, data) == meta + assert ( + serialization.deserialize_object( + ConversationMessageMeta, {"speaker": "a", "recipients": ["b"]} + ) + == meta + ) def test_message_metadata_is_required() -> None: - with pytest.raises(ValidationError): - load(ConversationMessage, {"textChunks": ["hi"]}) + with pytest.raises(serialization.DeserializationError, match="metadata"): + serialization.deserialize_object(ConversationMessage, {"textChunks": ["hi"]}) def test_message_source_id_round_trip() -> None: meta = ConversationMessageMeta(speaker="a") msg = ConversationMessage(text_chunks=["hi"], metadata=meta, source_id="s1") - data = dump(msg) + data = serialization.serialize_object(msg) assert data["sourceId"] == "s1" assert "source_id" not in data - assert load(ConversationMessage, data) == msg + assert serialization.deserialize_object(ConversationMessage, data) == msg # Data written before the alias generator used the Python name. data["source_id"] = data.pop("sourceId") - assert load(ConversationMessage, data) == msg + assert serialization.deserialize_object(ConversationMessage, data) == msg diff --git a/tests/test_email_message.py b/tests/test_email_message.py index 52d9c9ff..689f7c2f 100644 --- a/tests/test_email_message.py +++ b/tests/test_email_message.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from typeagent.emails.email_message import EmailMessage, EmailMessageMeta +from typeagent.knowpro import serialization def make_meta( @@ -230,14 +231,15 @@ def test_alias_migration_roundtrip(self) -> None: source_id="message-1", ) - data = msg.serialize() + data = serialization.serialize_object(msg) assert data["srcUrl"] == "mailbox/inbox.mbox" assert data["sourceId"] == "message-1" assert "src_url" not in data assert "source_id" not in data + assert serialization.deserialize_object(EmailMessage, data) == msg data["src_url"] = data.pop("srcUrl") data["source_id"] = data.pop("sourceId") - restored = EmailMessage.deserialize(data) + restored = serialization.deserialize_object(EmailMessage, data) assert restored.src_url == msg.src_url assert restored.source_id == msg.source_id From d88527f2b34df8e217b731787a75023471fbc05c Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sun, 18 Oct 2026 16:15:24 -0700 Subject: [PATCH 5/7] Fix dataclasses docstring and test the described behavior --- src/typeagent/knowpro/dataclasses.py | 9 ++++++--- tests/test_dataclasses.py | 29 +++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/typeagent/knowpro/dataclasses.py b/src/typeagent/knowpro/dataclasses.py index 44c4dd7f..726d4a93 100644 --- a/src/typeagent/knowpro/dataclasses.py +++ b/src/typeagent/knowpro/dataclasses.py @@ -2,9 +2,12 @@ # Licensed under the MIT License. """Pydantic dataclass decorator that adds camelCase aliases to every field. -Fields are declared in snake_case. On input, both the snake_case name and -its camelCase form are accepted; on output (``by_alias=True``), camelCase is -written. An explicit alias on a field takes precedence. +Fields are declared in snake_case. By default, both the snake_case name and +its generated camelCase form are accepted on input; output uses camelCase +when ``by_alias=True``. Explicit field aliases override the corresponding +generated aliases. A custom serialization alias needs a matching validation +alias if its output must round-trip; setting it alone does not change the +accepted input names. """ from collections.abc import Callable diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 76a48576..bf4410a1 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -4,7 +4,7 @@ import pytest -from pydantic import ConfigDict, ValidationError +from pydantic import ConfigDict, Field, ValidationError from typeagent.knowpro import serialization from typeagent.knowpro.dataclasses import dataclass @@ -65,6 +65,33 @@ def test_explicit_alias_wins() -> None: ) +@pytest.mark.parametrize("key", ["two_words", "twoWords"]) +def test_explicit_alias_overrides_generated_names(key: str) -> None: + @dataclass + class Aliased: + two_words: str = Field(alias="X") + + obj = load(Aliased, {"X": "y"}) + assert obj.two_words == "y" + assert dump(obj) == {"X": "y"} + assert load(Aliased, dump(obj)) == obj + with pytest.raises(ValidationError, match="Field required"): + load(Aliased, {key: "y"}) + + +@pytest.mark.parametrize("key", ["two_words", "twoWords"]) +def test_serialization_alias_does_not_change_input_names(key: str) -> None: + @dataclass + class OutputAlias: + two_words: str = Field(default="x", serialization_alias="X") + + obj = load(OutputAlias, {key: "y"}) + assert obj.two_words == "y" + assert dump(obj) == {"X": "y"} + # The custom output key is ignored unless a validation alias accepts it. + assert load(OutputAlias, dump(obj)).two_words == "x" + + def test_message_metadata_is_required() -> None: with pytest.raises(serialization.DeserializationError, match="metadata"): serialization.deserialize_object(ConversationMessage, {"textChunks": ["hi"]}) From 71d5950340be537b2ec2484b8abcb517a0f3ba46 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sun, 18 Oct 2026 16:17:25 -0700 Subject: [PATCH 6/7] Update CHANGELOG entry --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a5fdfc..44e4778e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,9 @@ decorator now gives every field a camelCase alias. As a result, the `source_id` field of messages and the `src_url` field of `EmailMessage` are now saved as `sourceId` and `srcUrl`; data saved under the old names still - loads. + loads. Other dataclasses using this decorator also serialize previously + unaliased multi-word fields as camelCase when `by_alias=True`; their + snake_case input names remain accepted unless explicitly overridden. ### 0.5.0 (May 26) From 641764b9df4c7b05a93344cc863359d042563949 Mon Sep 17 00:00:00 2001 From: Guido van Rossum Date: Sun, 18 Oct 2026 16:25:24 -0700 Subject: [PATCH 7/7] Clarifying comment why AliasChoices is needed --- src/typeagent/knowpro/dataclasses.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/typeagent/knowpro/dataclasses.py b/src/typeagent/knowpro/dataclasses.py index 726d4a93..3e7dda14 100644 --- a/src/typeagent/knowpro/dataclasses.py +++ b/src/typeagent/knowpro/dataclasses.py @@ -20,7 +20,10 @@ from pydantic.alias_generators import to_camel from pydantic.dataclasses import dataclass as _pydantic_dataclass -# AliasChoices rather than validate_by_name, which needs pydantic 2.11. +# Accept Python and camelCase names through generated validation aliases while +# preserving explicit field validation aliases. Unlike populate_by_name=True, +# this does not globally allow Python names to bypass explicit validation aliases. +# This works on pydantic 2.10 without validate_by_name, which requires 2.11. _ALIAS_GENERATOR = AliasGenerator( validation_alias=lambda name: AliasChoices(name, to_camel(name)), serialization_alias=to_camel,