Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 96 additions & 82 deletions mirth_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]"]]


Expand Down Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -198,15 +189,20 @@ 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.

By passing `content_type="xml"` to `XMLBaseModel.parse_raw` with an
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

Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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

Expand All @@ -336,7 +348,7 @@ class ChannelModel(XMLBaseModel):
__root_element__ = "channel"
id: UUID
name: str
description: Optional[str]
description: Optional[str] = None
revision: str


Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand All @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion tests/test_models/test_get_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions tests/test_models/test_get_channel_group_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand All @@ -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": [
{
Expand Down
4 changes: 2 additions & 2 deletions tests/test_models/test_get_channel_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand All @@ -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": [
{
Expand Down
Loading