diff --git a/mirth_client/models.py b/mirth_client/models.py index de1e992..9fda4ec 100644 --- a/mirth_client/models.py +++ b/mirth_client/models.py @@ -20,32 +20,18 @@ from uuid import UUID import xmltodict -import typing -import pydantic -from packaging import version from xml.parsers.expat import ExpatError -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, - root_validator, - validator, - ) - 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 @@ -55,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]"]] @@ -145,10 +134,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, populate_by_name=True) class XMLDict(OrderedDict): @@ -169,7 +155,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. @@ -187,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 @@ -198,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. @@ -207,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 @@ -221,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) @@ -275,18 +275,29 @@ class MirthDatetime(datetime): """ @classmethod - 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.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", + ), + ) @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): @@ -304,19 +315,20 @@ class ChannelGroup(XMLBaseModel): id: UUID name: str - description: Optional[str] + description: Optional[str] = None revision: str channels: List[GroupChannel] - @validator("channels", pre=True) - def strip_channels_roots(cls, value): # pylint: disable=no-self-use + @field_validator("channels", mode="before") + @classmethod + 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 @@ -336,7 +348,7 @@ class ChannelModel(XMLBaseModel): __root_element__ = "channel" id: UUID name: str - description: Optional[str] + description: Optional[str] = None revision: str @@ -387,8 +399,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 +440,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,27 +457,28 @@ 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) - def convert_hashmap(cls, value): # pylint: disable=no-self-use + @field_validator("meta_data_map", mode="before") + @classmethod + def convert_hashmap(cls, value): """Convert the XML hashmap into a Python dictionary""" return convert_hashmap(value) @@ -483,8 +496,9 @@ class ChannelMessageModel(XMLBaseModel): connector_messages: Dict[int, ConnectorMessageModel] - @validator("connector_messages", pre=True) - def convert_hashmap(cls, value): # pylint: disable=no-self-use + @field_validator("connector_messages", mode="before") + @classmethod + def convert_hashmap(cls, value): """Convert the XML hashmap into a Python dictionary""" return convert_hashmap(value) @@ -514,5 +528,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} 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",