# Copyright 2008-2021 pydicom authors. See LICENSE file for details. """Methods for converting Datasets and DataElements to/from json""" import base64 from inspect import signature from typing import TypeAlias, Any, cast, TYPE_CHECKING from collections.abc import Callable from pydicom.misc import warn_and_log from pydicom.valuerep import FLOAT_VR, INT_VR, VR if TYPE_CHECKING: # pragma: no cover from pydicom.dataset import Dataset JSON_VALUE_KEYS = ("Value", "BulkDataURI", "InlineBinary") def convert_to_python_number(value: Any, vr: str) -> Any: """When possible convert numeric-like values to either ints or floats based on their value representation. Parameters ---------- value : Any Value of the data element. vr : str Value representation of the data element. Returns ------- Any * If `value` is empty then returns the `value` unchanged. * If `vr` is an integer-like VR type then returns ``int`` or ``List[int]`` * If `vr` is a float-like VR type then returns ``float`` or ``List[float]`` * Otherwise returns `value` unchanged """ from pydicom.dataelem import empty_value_for_VR if value is None or "": return value number_type: type[int] | type[float] | None = None if vr in (INT_VR - {VR.AT}) | {VR.US_SS}: number_type = int if vr in FLOAT_VR: number_type = float if number_type is None: return value if isinstance(value, list | tuple): return [ number_type(v) if v is not None else empty_value_for_VR(vr) for v in value ] return number_type(value) OtherValueType = None | str | int | float PNValueType = None | str | dict[str, str] SQValueType = dict[str, Any] | None # Recursive ValueType: TypeAlias = PNValueType | SQValueType | OtherValueType InlineBinaryType: TypeAlias = str | list[str] BulkDataURIType: TypeAlias = str | list[str] JSONValueType = list[ValueType] | InlineBinaryType | BulkDataURIType BulkDataType = None | str | int | float | bytes BulkDataHandlerType = Callable[[str, str, str], BulkDataType] | None class JsonDataElementConverter: """Convert from a JSON struct to a :class:`DataElement`. References ---------- * :dcm:`Annex F of Part 18 of the DICOM Standard` * `JSON to Python object conversion table `_ """ def __init__( self, dataset_class: type["Dataset"], tag: str, vr: str, value: JSONValueType, value_key: str | None, bulk_data_uri_handler: ( BulkDataHandlerType | Callable[[str], BulkDataType] | None ) = None, ) -> None: """Create a new converter instance. Parameters ---------- dataset_class : dataset.Dataset derived class The class object to use for **SQ** element items. tag : str The data element's tag in uppercase hex format like ``"7FE00010"``. vr : str The data element value representation. value : str or List[None | str | int | float | dict] The attribute value for the JSON object's "Value", "InlineBinary" or "BulkDataURI" field. If there's no such attribute then `value` will be ``[""]``. value_key : str or None The attribute name for `value`, should be one of: ``{"Value", "InlineBinary", "BulkDataURI"}``. If the element's VM is ``0`` and none of the keys are used then will be ``None``. bulk_data_uri_handler: callable, optional Callable function that accepts either the `tag`, `vr` and the "BulkDataURI" `value`, or just the "BulkDataURI" `value` of the JSON representation of a data element and returns the actual value of that data element (retrieved via DICOMweb WADO-RS). If no `bulk_data_uri_handler` is specified (default) then the corresponding element will have an "empty" value such as ``""``, ``b""`` or ``None`` depending on the `vr` (i.e. the Value Multiplicity will be 0). """ self.dataset_class = dataset_class self.tag = tag self.vr = vr self.value = value self.value_key = value_key self.bulk_data_element_handler: BulkDataHandlerType handler = bulk_data_uri_handler if handler and len(signature(handler).parameters) == 1: # `handler` is Callable[[str], BulkDataType] def wrapper(tag: str, vr: str, value: str) -> BulkDataType: x = cast(Callable[[str], BulkDataType], handler) return x(value) self.bulk_data_element_handler = wrapper else: self.bulk_data_element_handler = cast(BulkDataHandlerType, handler) def get_element_values(self) -> Any: """Return a the data element value or list of values. Returns ------- None, str, float, int, bytes, dataset_class or a list of these The value or value list of the newly created data element. """ from pydicom.dataelem import empty_value_for_VR # An attribute with an empty value should have no "Value", # "BulkDataURI" or "InlineBinary" if self.value_key is None: return empty_value_for_VR(self.vr) if self.value_key == "Value": if not isinstance(self.value, list): raise TypeError( f"'{self.value_key}' of data element '{self.tag}' must be a list" ) if not self.value: return empty_value_for_VR(self.vr) val = cast(list[ValueType], self.value) element_value = [self.get_regular_element_value(v) for v in val] if len(element_value) == 1 and self.vr != VR.SQ: element_value = element_value[0] return convert_to_python_number(element_value, self.vr) # The value for "InlineBinary" shall be encoded as a base64 encoded # string, as shown in PS3.18, Table F.3.1-1, but the example in # PS3.18, Annex F.4 shows the string enclosed in a list. # We support both variants, as the standard is ambiguous here, # and do the same for "BulkDataURI". value = cast(str | list[str], self.value) if isinstance(value, list): value = value[0] if self.value_key == "InlineBinary": # The `value` should be a base64 encoded str if not isinstance(value, str): raise TypeError( f"Invalid attribute value for data element '{self.tag}' - " "the value for 'InlineBinary' must be str, not " f"{type(value).__name__}" ) return base64.b64decode(value) # bytes if self.value_key == "BulkDataURI": # The `value` should be a URI as a str if not isinstance(value, str): raise TypeError( f"Invalid attribute value for data element '{self.tag}' - " "the value for 'BulkDataURI' must be str, not " f"{type(value).__name__}" ) if self.bulk_data_element_handler is None: warn_and_log( "No bulk data URI handler provided for retrieval " f'of value of data element "{self.tag}"' ) return empty_value_for_VR(self.vr) return self.bulk_data_element_handler(self.tag, self.vr, value) raise ValueError( f"Unknown attribute name '{self.value_key}' for tag {self.tag}" ) def get_regular_element_value(self, value: ValueType) -> Any: """Return a the data element value created from a json "Value" entry. Parameters ---------- value : None, str, int, float or dict The data element's value from the json entry. Returns ------- None, str, int, float or Dataset A single value of the corresponding :class:`DataElement`. """ from pydicom.dataelem import empty_value_for_VR # Table F.2.3-1 has JSON type mappings if self.vr == VR.SQ: # Dataset # May be an empty dict value = cast(dict[str, Any], value) return self.get_sequence_item(value) if value is None: return empty_value_for_VR(self.vr) if self.vr == VR.PN: # str value = cast(dict[str, str], value) return self.get_pn_element_value(value) if self.vr == VR.AT: # Optional[int] # May be an empty str value = cast(str, value) try: return int(value, 16) except ValueError: warn_and_log(f"Invalid value '{value}' for AT element - ignoring it") return None return value def get_sequence_item(self, value: SQValueType) -> "Dataset": """Return a sequence item for the JSON dict `value`. Parameters ---------- value : dict or None The sequence item from the JSON entry. Returns ------- dataset_class The decoded dataset item. Raises ------ KeyError If the "vr" key is missing for a contained element """ from pydicom import DataElement from pydicom.dataelem import empty_value_for_VR ds = self.dataset_class() value = {} if value is None else value for key, val in value.items(): if "vr" not in val: raise KeyError(f"Data element '{self.tag}' must have key 'vr'") vr = val["vr"] unique_value_keys = tuple(set(val.keys()) & set(JSON_VALUE_KEYS)) if not unique_value_keys: # data element with no value elem = DataElement( tag=int(key, 16), value=empty_value_for_VR(vr), VR=vr ) else: value_key = unique_value_keys[0] elem = DataElement.from_json( self.dataset_class, key, vr, val[value_key], value_key, self.bulk_data_element_handler, ) ds.add(elem) return ds def get_pn_element_value(self, value: str | dict[str, str]) -> str: """Return a person name from JSON **PN** value as str. Values with VR PN have a special JSON encoding, see the DICOM Standard, Part 18, :dcm:`Annex F.2.2`. Parameters ---------- value : Dict[str, str] The person name components in the JSON entry. Returns ------- str The decoded PersonName object or an empty string. """ if not isinstance(value, dict): # Some DICOMweb services get this wrong, so we # workaround the issue and warn the user # rather than raising an error. warn_and_log( f"Value of data element '{self.tag}' with VR Person Name (PN) " "is not formatted correctly" ) return value if "Phonetic" in value: comps = ["", "", ""] elif "Ideographic" in value: comps = ["", ""] else: comps = [""] if "Alphabetic" in value: comps[0] = value["Alphabetic"] if "Ideographic" in value: comps[1] = value["Ideographic"] if "Phonetic" in value: comps[2] = value["Phonetic"] return "=".join(comps)