From b7a529762b6ee01a0d221d366926226cc2ffdfde Mon Sep 17 00:00:00 2001 From: OliveraReeves Date: Tue, 14 Jul 2026 12:48:34 +0100 Subject: [PATCH 1/3] dropped pydantic v1 support --- mirth_client/models.py | 56 +++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/mirth_client/models.py b/mirth_client/models.py index de1e992..e47951b 100644 --- a/mirth_client/models.py +++ b/mirth_client/models.py @@ -24,6 +24,7 @@ import pydantic from packaging import version from xml.parsers.expat import ExpatError +from pydantic import field_validator, model_validator, ConfigDict if typing.TYPE_CHECKING or version.parse(pydantic.__version__) >= version.parse("2.0"): from pydantic.v1 import ( @@ -40,10 +41,7 @@ BaseModel, Protocol, StrBytes, - ValidationError, - root_validator, - validator, - ) + ValidationError) from pydantic.error_wrappers import ErrorWrapper @@ -144,11 +142,7 @@ class MirthBaseModel(BaseModel): """ Base model which defaults to creating camelCase aliases for all fields """ - - class Config: - """Pydantic config class to set alias generator""" - - alias_generator = _to_camel + model_config = ConfigDict(alias_generator=_to_camel) class XMLDict(OrderedDict): @@ -169,7 +163,8 @@ class XMLBaseModel(MirthBaseModel): __root_element__: str = "" __force_list__: Union[Tuple, Tuple[str], bool] = tuple() - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def strip_xml_root(cls, value): """Strips out the root key of a parsed XML message, if one is defined on the model. @@ -275,6 +270,8 @@ class MirthDatetime(datetime): """ @classmethod + # TODO[pydantic]: We couldn't refactor `__get_validators__`, please create the `__get_pydantic_core_schema__` manually. + # Check https://docs.pydantic.dev/latest/migration/#defining-custom-types for more information. def __get_validators__(cls): yield cls.validate @@ -304,12 +301,13 @@ class ChannelGroup(XMLBaseModel): id: UUID name: str - description: Optional[str] + description: Optional[str] = None revision: str channels: List[GroupChannel] - @validator("channels", pre=True) + @field_validator("channels", mode="before") + @classmethod def strip_channels_roots(cls, value): # pylint: disable=no-self-use """ Extract the actual GroupChannel elements from the parsed-XML dictionary. @@ -336,7 +334,7 @@ class ChannelModel(XMLBaseModel): __root_element__ = "channel" id: UUID name: str - description: Optional[str] + description: Optional[str] = None revision: str @@ -387,8 +385,8 @@ class EventModel(XMLBaseModel): name: str outcome: str attributes: Dict - user_id: Optional[str] - ip_address: Optional[str] + user_id: Optional[str] = None + ip_address: Optional[str] = None date_time: datetime @@ -428,12 +426,12 @@ class ConnectorMessageData(MirthBaseModel): """Object mapping for connectorMessage `raw` or `parsed` data""" channel_id: UUID - content: Optional[str] + content: Optional[str] = None content_type: str - data_type: Optional[str] + data_type: Optional[str] = None encrypted: bool message_id: str - message_data_id: Optional[str] + message_data_id: Optional[str] = None class ConnectorMessageModel(XMLBaseModel): @@ -445,26 +443,27 @@ class ConnectorMessageModel(XMLBaseModel): server_id: UUID channel_id: str - status: Optional[str] + status: Optional[str] = None received_date: MirthDatetime channel_name: str - connector_name: Optional[str] + connector_name: Optional[str] = None message_id: str error_code: int send_attempts: int - raw: Optional[ConnectorMessageData] - encoded: Optional[ConnectorMessageData] - sent: Optional[ConnectorMessageData] - response: Optional[ConnectorMessageData] + raw: Optional[ConnectorMessageData] = None + encoded: Optional[ConnectorMessageData] = None + sent: Optional[ConnectorMessageData] = None + response: Optional[ConnectorMessageData] = None meta_data_id: int meta_data_map: Dict[str, Optional[str]] - @validator("meta_data_map", pre=True) + @field_validator("meta_data_map", mode="before") + @classmethod def convert_hashmap(cls, value): # pylint: disable=no-self-use """Convert the XML hashmap into a Python dictionary""" return convert_hashmap(value) @@ -483,7 +482,8 @@ class ChannelMessageModel(XMLBaseModel): connector_messages: Dict[int, ConnectorMessageModel] - @validator("connector_messages", pre=True) + @field_validator("connector_messages", mode="before") + @classmethod def convert_hashmap(cls, value): # pylint: disable=no-self-use """Convert the XML hashmap into a Python dictionary""" return convert_hashmap(value) @@ -514,5 +514,5 @@ class MirthErrorMessageModel(XMLBaseModel): status: str message: str - error: Optional[str] - status_message: Optional[str] + error: Optional[str] = None + status_message: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index 995a4f1..feac6a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ version = "4.2.0" [tool.poetry.dependencies] Sphinx = {version = ">=3.5.3,<8.0.0", optional = true} httpx = ">=0.23,<0.28.2" -pydantic = ">=1.8.2,<3.0.0" +pydantic = ">=2.0.0,<3.0.0" python = "^3.9" semver = ">=2.13,<4.0" sphinx-rtd-theme = {version = ">=0.5.1,<3.2.0", optional = true} From fb741d41b70ab6bad253b7f2fd393351e24889de Mon Sep 17 00:00:00 2001 From: OliveraReeves Date: Tue, 14 Jul 2026 13:47:40 +0100 Subject: [PATCH 2/3] dropped pydantic v1 support --- mirth_client/models.py | 133 ++++++++++-------- tests/test_models/test_get_channel.py | 2 +- .../test_get_channel_group_list.py | 4 +- tests/test_models/test_get_channel_list.py | 4 +- tests/test_models/test_get_channel_message.py | 6 +- .../test_models/test_get_channel_messages.py | 6 +- .../test_get_channel_statistics.py | 2 +- .../test_get_channel_statistics_list.py | 6 +- .../test_get_channel_status_list.py | 8 +- tests/test_models/test_get_event.py | 2 +- tests/test_models/test_get_event_list.py | 4 +- tests/test_models/test_login_response.py | 2 +- 12 files changed, 101 insertions(+), 78 deletions(-) diff --git a/mirth_client/models.py b/mirth_client/models.py index e47951b..7323b90 100644 --- a/mirth_client/models.py +++ b/mirth_client/models.py @@ -20,30 +20,18 @@ from uuid import UUID import xmltodict -import typing -import pydantic -from packaging import version from xml.parsers.expat import ExpatError -from pydantic import field_validator, model_validator, ConfigDict - -if typing.TYPE_CHECKING or version.parse(pydantic.__version__) >= version.parse("2.0"): - from pydantic.v1 import ( - BaseModel, - ValidationError, - root_validator, - validator, - StrBytes, - Protocol, - ) - from pydantic.v1.error_wrappers import ErrorWrapper -else: - from pydantic import ( - BaseModel, - Protocol, - StrBytes, - ValidationError) - from pydantic.error_wrappers import ErrorWrapper +from pydantic import ( + BaseModel, + ConfigDict, + ValidationError, + field_validator, + model_validator, +) +from pydantic.deprecated.parse import Protocol as DeprecatedParseProtocol +from pydantic.main import IncEx +from pydantic_core import InitErrorDetails from typing_extensions import TypedDict @@ -53,6 +41,9 @@ SetIntStr = Set[IntStr] DictIntStrAny = Dict[IntStr, Any] +# pydantic v1's `StrBytes` alias, kept for internal use +StrBytes = Union[str, bytes] + _RawHashMapTypes = Union["OrderedDict[Any, Any]", List["OrderedDict[Any, Any]"]] @@ -142,7 +133,8 @@ class MirthBaseModel(BaseModel): """ Base model which defaults to creating camelCase aliases for all fields """ - model_config = ConfigDict(alias_generator=_to_camel) + + model_config = ConfigDict(alias_generator=_to_camel, populate_by_name=True) class XMLDict(OrderedDict): @@ -182,7 +174,11 @@ def strip_xml_root(cls, value): Returns: dict: Dictionary without root key """ - if cls.__root_element__ and cls.__root_element__ in value: + if ( + cls.__root_element__ + and isinstance(value, dict) + and cls.__root_element__ in value + ): return value[cls.__root_element__] return value @@ -193,8 +189,8 @@ def parse_raw( # pylint: disable=arguments-differ *, content_type: Optional[str] = "xml", encoding: str = "utf8", - proto: Optional[Protocol] = None, - allow_pickle: bool = False, + proto: Optional["DeprecatedParseProtocol"] = None, # noqa: ARG003 + allow_pickle: bool = False, # noqa: ARG003 ) -> "Model": """Parse raw data into a Pydantic object. @@ -202,6 +198,11 @@ def parse_raw( # pylint: disable=arguments-differ XML formatted input string, the XML is parsed to a dictionary object and send for validation by the Pydantic model. + Note: + `proto` and `allow_pickle` are accepted only to keep this + method's signature compatible with the deprecated + `BaseModel.parse_raw` it overrides; they are not used. + Raises: ValidationError: Invalid XML or XML can not be validated against the model @@ -216,40 +217,44 @@ def parse_raw( # pylint: disable=arguments-differ encoding="utf-8" if encoding == "utf8" else encoding, dict_constructor=XMLDict, ) - return cls.parse_obj(obj) + return cls.model_validate(obj) except ( ValueError, TypeError, UnicodeDecodeError, ExpatError, # pylint: disable=no-member ) as e: - raise ValidationError([ErrorWrapper(e, loc="__obj__")], cls) from e - return super().parse_raw( # type: ignore - b, - content_type=content_type, # type: ignore - encoding=encoding, - proto=proto, # type: ignore - allow_pickle=allow_pickle, - ) + raise ValidationError.from_exception_data( + cls.__name__, + [ + InitErrorDetails( + type="value_error", + loc=("__obj__",), + input=b, + ctx={"error": e}, + ) + ], + ) from e + # Fall back to standard JSON parsing behaviour + return cls.model_validate_json(b) def xml( self, *, - include: Union["SetIntStr", "DictIntStrAny", None] = None, - exclude: Union["SetIntStr", "DictIntStrAny", None] = None, + include: "IncEx | None" = None, + exclude: "IncEx | None" = None, by_alias: bool = True, exclude_unset: bool = False, ) -> str: """Convert the Pydantic object into an XML string""" + root_key = self.__class__.__root_element__ or self.__class__.__name__ xml_dict = { - self.__class__.__root_element__ - or self.__class__.__name__: self.__config__.json_loads( - self.json( - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - ) + root_key: self.model_dump( + mode="json", + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, ) } return xmltodict.unparse(xml_dict) @@ -270,20 +275,28 @@ class MirthDatetime(datetime): """ @classmethod - # TODO[pydantic]: We couldn't refactor `__get_validators__`, please create the `__get_pydantic_core_schema__` manually. - # Check https://docs.pydantic.dev/latest/migration/#defining-custom-types for more information. - def __get_validators__(cls): - yield cls.validate + def __get_pydantic_core_schema__(cls, source_type, handler): + from pydantic_core import core_schema + + return core_schema.no_info_plain_validator_function( + cls.validate, + serialization=core_schema.plain_serializer_function_ser_schema( + lambda v: v.isoformat(), + when_used="json", + ), + ) @classmethod - def validate(cls, value: _MirthDateTimeMap): + def validate(cls, value): """Extract timestamp and convert to a datetime""" - if not isinstance(value, cls): - timestamp = value.get("time") - if not timestamp: - raise ValueError("No `time` attribute found in input") - return cls.fromtimestamp(int(timestamp) / 1000) - return value + if isinstance(value, cls): + return value + if isinstance(value, datetime): + return value + timestamp = value.get("time") + if not timestamp: + raise ValueError("No `time` attribute found in input") + return cls.fromtimestamp(int(timestamp) / 1000) class GroupChannel(XMLBaseModel): @@ -308,13 +321,13 @@ class ChannelGroup(XMLBaseModel): @field_validator("channels", mode="before") @classmethod - def strip_channels_roots(cls, value): # pylint: disable=no-self-use + def strip_channels_roots(cls, value): """ Extract the actual GroupChannel elements from the parsed-XML dictionary. The 'GroupChannel' element contains an element 'channel', which contains a list of GroupChannel elements which we actually want. """ - if "channel" in value: + if isinstance(value, dict) and "channel" in value: return value["channel"] return value @@ -464,7 +477,7 @@ class ConnectorMessageModel(XMLBaseModel): @field_validator("meta_data_map", mode="before") @classmethod - def convert_hashmap(cls, value): # pylint: disable=no-self-use + def convert_hashmap(cls, value): """Convert the XML hashmap into a Python dictionary""" return convert_hashmap(value) @@ -484,7 +497,7 @@ class ChannelMessageModel(XMLBaseModel): @field_validator("connector_messages", mode="before") @classmethod - def convert_hashmap(cls, value): # pylint: disable=no-self-use + def convert_hashmap(cls, value): """Convert the XML hashmap into a Python dictionary""" return convert_hashmap(value) diff --git a/tests/test_models/test_get_channel.py b/tests/test_models/test_get_channel.py index df00794..3eae177 100644 --- a/tests/test_models/test_get_channel.py +++ b/tests/test_models/test_get_channel.py @@ -10,7 +10,7 @@ def test_xml_to_obj(): - response = ChannelModel.parse_raw(CHANNEL_RESPONSE) + response = ChannelModel.parse_raw(CHANNEL_RESPONSE).model_dump() assert response == { "id": UUID("7e9ec9f5-5d48-4216-b10f-a32587cf8647"), "name": "Channel 1", diff --git a/tests/test_models/test_get_channel_group_list.py b/tests/test_models/test_get_channel_group_list.py index eb5bb16..4b2f641 100644 --- a/tests/test_models/test_get_channel_group_list.py +++ b/tests/test_models/test_get_channel_group_list.py @@ -18,7 +18,7 @@ def test_xml_to_obj(): - response = GroupList.parse_raw(CHANNEL_GROUP_LIST_RESPONSE) + response = GroupList.parse_raw(CHANNEL_GROUP_LIST_RESPONSE).model_dump() assert response == { "channel_group": [ { @@ -38,7 +38,7 @@ def test_xml_to_obj(): def test_xml_to_obj_multiple(): - response = GroupList.parse_raw(CHANNEL_GROUP_LIST_RESPONSE_MULTIPLE) + response = GroupList.parse_raw(CHANNEL_GROUP_LIST_RESPONSE_MULTIPLE).model_dump() assert response == { "channel_group": [ { diff --git a/tests/test_models/test_get_channel_list.py b/tests/test_models/test_get_channel_list.py index 7266ef7..e23ad72 100644 --- a/tests/test_models/test_get_channel_list.py +++ b/tests/test_models/test_get_channel_list.py @@ -16,7 +16,7 @@ def test_xml_to_obj(): - response = ChannelList.parse_raw(CHANNEL_LIST_RESPONSE) + response = ChannelList.parse_raw(CHANNEL_LIST_RESPONSE).model_dump() assert response == { "channel": [ { @@ -30,7 +30,7 @@ def test_xml_to_obj(): def test_xml_to_obj_multiple(): - response = ChannelList.parse_raw(CHANNEL_LIST_RESPONSE_MULTIPLE) + response = ChannelList.parse_raw(CHANNEL_LIST_RESPONSE_MULTIPLE).model_dump() assert response == { "channel": [ { diff --git a/tests/test_models/test_get_channel_message.py b/tests/test_models/test_get_channel_message.py index e600e3f..3b699b6 100644 --- a/tests/test_models/test_get_channel_message.py +++ b/tests/test_models/test_get_channel_message.py @@ -16,7 +16,7 @@ def test_xml_to_obj(): - response = ChannelMessageModel.parse_raw(CHANNEL_MESSAGE_RESPONSE) + response = ChannelMessageModel.parse_raw(CHANNEL_MESSAGE_RESPONSE).model_dump() assert response == { "message_id": 1, "server_id": UUID("4975776f-deb5-4ac6-ba3c-60b27198983d"), @@ -48,7 +48,9 @@ def test_xml_to_obj(): def test_xml_to_obj_message_with_error(): - response = ChannelMessageModel.parse_raw(CHANNEL_MESSAGE_RESPONSE_ERROR) + response = ChannelMessageModel.parse_raw( + CHANNEL_MESSAGE_RESPONSE_ERROR + ).model_dump() assert response == { "message_id": 1, "server_id": UUID("176fbc8a-2718-4d78-beee-7530ec068ee9"), diff --git a/tests/test_models/test_get_channel_messages.py b/tests/test_models/test_get_channel_messages.py index 448e3d6..5dc2ff0 100644 --- a/tests/test_models/test_get_channel_messages.py +++ b/tests/test_models/test_get_channel_messages.py @@ -16,7 +16,7 @@ def test_xml_to_obj(): - response = ChannelMessageList.parse_raw(CHANNEL_MESSAGES_RESPONSE) + response = ChannelMessageList.parse_raw(CHANNEL_MESSAGES_RESPONSE).model_dump() assert response == { "message": [ { @@ -52,7 +52,9 @@ def test_xml_to_obj(): def test_xml_to_obj_multiple(): - response = ChannelMessageList.parse_raw(CHANNEL_MESSAGES_RESPONSE_MULTIPLE) + response = ChannelMessageList.parse_raw( + CHANNEL_MESSAGES_RESPONSE_MULTIPLE + ).model_dump() assert response == { "message": [ { diff --git a/tests/test_models/test_get_channel_statistics.py b/tests/test_models/test_get_channel_statistics.py index 3bfa8f7..0c32373 100644 --- a/tests/test_models/test_get_channel_statistics.py +++ b/tests/test_models/test_get_channel_statistics.py @@ -10,7 +10,7 @@ def test_xml_to_obj(): - response = ChannelStatistics.parse_raw(CHANNEL_STATISTICS_RESPONSE) + response = ChannelStatistics.parse_raw(CHANNEL_STATISTICS_RESPONSE).model_dump() assert response == { "server_id": UUID("fb4966f7-891a-485d-ba1a-69f5b0e5147f"), "channel_id": UUID("40f6d5fc-0d1f-4163-bb9b-9f64c9841c33"), diff --git a/tests/test_models/test_get_channel_statistics_list.py b/tests/test_models/test_get_channel_statistics_list.py index 7090eaf..bff70fc 100644 --- a/tests/test_models/test_get_channel_statistics_list.py +++ b/tests/test_models/test_get_channel_statistics_list.py @@ -18,7 +18,9 @@ def test_xml_to_obj(): - response = ChannelStatisticsList.parse_raw(CHANNEL_STATISTICS_LIST_RESPONSE) + response = ChannelStatisticsList.parse_raw( + CHANNEL_STATISTICS_LIST_RESPONSE + ).model_dump() assert response == { "channel_statistics": [ { @@ -37,7 +39,7 @@ def test_xml_to_obj(): def test_xml_to_obj_multiple(): response = ChannelStatisticsList.parse_raw( CHANNEL_STATISTICS_LIST_RESPONSE_MULTIPLE - ) + ).model_dump() assert response == { "channel_statistics": [ { diff --git a/tests/test_models/test_get_channel_status_list.py b/tests/test_models/test_get_channel_status_list.py index b4d9912..ce18ffe 100644 --- a/tests/test_models/test_get_channel_status_list.py +++ b/tests/test_models/test_get_channel_status_list.py @@ -18,7 +18,9 @@ def test_xml_to_obj(): - response = DashboardStatusList.parse_raw(DASHBOARD_STATUS_LIST_RESPONSE) + response = DashboardStatusList.parse_raw( + DASHBOARD_STATUS_LIST_RESPONSE + ).model_dump() assert response == { "dashboard_status": [ { @@ -33,7 +35,9 @@ def test_xml_to_obj(): def test_xml_to_obj_multiple(): - response = DashboardStatusList.parse_raw(DASHBOARD_STATUS_LIST_RESPONSE_MULTIPLE) + response = DashboardStatusList.parse_raw( + DASHBOARD_STATUS_LIST_RESPONSE_MULTIPLE + ).model_dump() assert response == { "dashboard_status": [ { diff --git a/tests/test_models/test_get_event.py b/tests/test_models/test_get_event.py index 58346a0..090a5d6 100644 --- a/tests/test_models/test_get_event.py +++ b/tests/test_models/test_get_event.py @@ -10,7 +10,7 @@ def test_xml_to_obj(): - response = EventModel.parse_raw(EVENT_RESPONSE) + response = EventModel.parse_raw(EVENT_RESPONSE).model_dump() assert response == { "id": 0, "level": "INFORMATION", diff --git a/tests/test_models/test_get_event_list.py b/tests/test_models/test_get_event_list.py index abf477c..9f5a449 100644 --- a/tests/test_models/test_get_event_list.py +++ b/tests/test_models/test_get_event_list.py @@ -18,7 +18,7 @@ def test_xml_to_obj(): - response = EventList.parse_raw(EVENT_LIST_RESPONSE) + response = EventList.parse_raw(EVENT_LIST_RESPONSE).model_dump() assert response == { "event": [ { @@ -41,7 +41,7 @@ def test_xml_to_obj(): def test_xml_to_obj_multiple(): - response = EventList.parse_raw(EVENT_LIST_RESPONSE_MULTIPLE) + response = EventList.parse_raw(EVENT_LIST_RESPONSE_MULTIPLE).model_dump() assert response == { "event": [ { diff --git a/tests/test_models/test_login_response.py b/tests/test_models/test_login_response.py index 760824d..994ee1e 100644 --- a/tests/test_models/test_login_response.py +++ b/tests/test_models/test_login_response.py @@ -10,7 +10,7 @@ def test_xml_to_obj(): login_response = LoginResponse.parse_raw(LOGIN_RESPONSE) - assert login_response == { + assert login_response.model_dump() == { "status": "SUCCESS", "message": None, "updated_username": "newUserName", From a79e8dd53dd061229115b5549d104b81429b2871 Mon Sep 17 00:00:00 2001 From: OliveraReeves Date: Tue, 14 Jul 2026 14:03:10 +0100 Subject: [PATCH 3/3] fix --- mirth_client/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mirth_client/models.py b/mirth_client/models.py index 7323b90..9fda4ec 100644 --- a/mirth_client/models.py +++ b/mirth_client/models.py @@ -278,8 +278,9 @@ class MirthDatetime(datetime): def __get_pydantic_core_schema__(cls, source_type, handler): from pydantic_core import core_schema - return core_schema.no_info_plain_validator_function( - cls.validate, + return core_schema.json_or_python_schema( + json_schema=core_schema.str_schema(), + python_schema=core_schema.no_info_plain_validator_function(cls.validate), serialization=core_schema.plain_serializer_function_ser_schema( lambda v: v.isoformat(), when_used="json",