From d38eb183eeddafdcb6cb839967cc2797f34c124a Mon Sep 17 00:00:00 2001 From: scaramallion Date: Fri, 17 Nov 2023 21:59:48 +1100 Subject: [PATCH] Add convenience function Event.encoded_dataset() (#888) --- docs/changelog/v2.1.0.rst | 11 ++- docs/examples/storage.rst | 15 +-- docs/tutorials/create_scp.rst | 33 +++---- pynetdicom/apps/common.py | 8 +- pynetdicom/dsutils.py | 25 +++-- pynetdicom/events.py | 169 ++++++++++++++++++++------------ pynetdicom/tests/test_events.py | 41 ++++++++ 7 files changed, 193 insertions(+), 109 deletions(-) diff --git a/docs/changelog/v2.1.0.rst b/docs/changelog/v2.1.0.rst index 61229ca7a2..0a7c0cb617 100644 --- a/docs/changelog/v2.1.0.rst +++ b/docs/changelog/v2.1.0.rst @@ -9,9 +9,9 @@ Fixes * Fixed reserved A-ASSOCIATE-AC parameters being tested (:issue:`746`) * Fixed datasets not transferring correctly when using :attr:`~pynetdicom._config.STORE_RECV_CHUNKED_DATASET` (:issue:`756`) -* Fixed maximum length of PatientID attribute in qrscp app (:issue:`785`) -* Sanitize filenames for received datasets for non-conformant SOP Instance UIDs - (:issue:`823`) +* Fixed maximum length of *Patient ID* attribute in ``qrscp`` app (:issue:`785`) +* Sanitise filenames for received datasets for non-conformant SOP Instance + UIDs (:issue:`823`) Enhancements ............ @@ -23,8 +23,11 @@ Enhancements :class:`~pynetdicom.service_class.QueryRetrieveServiceClass` (:issue:`878`) * Added support for :class:`Inventory Query/Retrieve Service Class ` (:issue:`879`) +* Added :meth:`~pynetdicom.events.Event.encoded_dataset` to simplify accessing + the encoded dataset without first decoding it Changes ....... -* Add preliminary support for Python 3.12 +* Dropped support for Python 3.7, 3.8 and 3.9 +* Added support for Python 3.12 diff --git a/docs/examples/storage.rst b/docs/examples/storage.rst index 9170756627..84c8de333e 100644 --- a/docs/examples/storage.rst +++ b/docs/examples/storage.rst @@ -157,21 +157,16 @@ multiple C-STORE requests, depending on the size of the datasets: .. code-block:: python - from pydicom.filewriter import write_file_meta_info - + import uuid from pynetdicom import AE, evt, AllStoragePresentationContexts # Implement a handler for evt.EVT_C_STORE def handle_store(event): """Handle a C-STORE request event.""" - with open(event.request.AffectedSOPInstanceUID, 'wb') as f: - # Write the preamble and prefix - f.write(b'\x00' * 128) - f.write(b'DICM') - # Encode and write the File Meta Information - write_file_meta_info(f, event.file_meta) - # Write the encoded dataset - f.write(event.request.DataSet.getvalue()) + with open(f"{uuid.uuid4()}", 'wb') as f: + # Write the preamble, prefix, file meta information + # and encoded dataset to `f` + f.write(event.encoded_dataset()) # Return a 'Success' status return 0x0000 diff --git a/docs/tutorials/create_scp.rst b/docs/tutorials/create_scp.rst index 995d6e482a..359c967006 100644 --- a/docs/tutorials/create_scp.rst +++ b/docs/tutorials/create_scp.rst @@ -397,11 +397,10 @@ complex code: .. code-block:: python :linenos: - :emphasize-lines: 1,3,12,14-18,21-28,32 + :emphasize-lines: 1-2,11,13-17,19-23,27 - import os - - from pydicom.filewriter import write_file_meta_info + import uuid + from pathlib import Path from pynetdicom import ( AE, debug_logger, evt, AllStoragePresentationContexts, @@ -418,15 +417,11 @@ complex code: # Unable to create output dir, return failure status return 0xC001 - # We rely on the UID from the C-STORE request instead of decoding - fname = os.path.join(storage_dir, event.request.AffectedSOPInstanceUID) - with open(fname, 'wb') as f: - # Write the preamble, prefix and file meta information elements - f.write(b'\x00' * 128) - f.write(b'DICM') - write_file_meta_info(f, event.file_meta) - # Write the raw encoded dataset - f.write(event.request.DataSet.getvalue()) + path = Path(storage_dir) / f"{uuid.uuid4()}" + with path.open('wb') as f: + # Write the preamble, prefix, file meta information elements + # and the raw encoded dataset to `f` + f.write(event.encoded_dataset()) return 0x0000 @@ -441,12 +436,12 @@ complex code: ae.start_server(("127.0.0.1", 11112), block=True, evt_handlers=handlers) -We've modified the handler to write the preamble and prefix to file, -encode and write the file meta information elements using *pydicom's* -:func:`~pydicom.filewriter.write_file_meta_info` function, then finally write -the encoded dataset using the :attr:`raw dataset -` received directly from the C-STORE -request via the ``event.request`` attribute. +We've modified the handler to use :meth:`~pynetdicom.events.Event.encoded_dataset`, +which writes the preamble, prefix, file meta information elements and the +:attr:`raw dataset` received in the C-STORE +request directly to file. If you need separate access to just the encoded dataset +then you can call :meth:`~pynetdicom.events.Event.encoded_dataset` with +`include_meta=False` instead. The second change we've made is to demonstrate how extra parameters can be passed to the handler by binding using a 3-tuple rather than a 2-tuple. The diff --git a/pynetdicom/apps/common.py b/pynetdicom/apps/common.py index 657199ab81..6c92323564 100644 --- a/pynetdicom/apps/common.py +++ b/pynetdicom/apps/common.py @@ -8,12 +8,9 @@ from pydicom import dcmread from pydicom.datadict import tag_for_keyword, repeater_has_keyword, get_entry from pydicom.dataset import Dataset -from pydicom.filewriter import write_file_meta_info from pydicom.tag import Tag from pydicom.uid import DeflatedExplicitVRLittleEndian -from pynetdicom.dsutils import encode - def create_dataset(args, logger=None): """Return a new or updated dataset. @@ -629,10 +626,7 @@ def handle_store(event, args, app_logger): if event.context.transfer_syntax == DeflatedExplicitVRLittleEndian: # Workaround for pydicom issue #1086 with open(filename, "wb") as f: - f.write(b"\x00" * 128) - f.write(b"DICM") - write_file_meta_info(f, event.file_meta) - f.write(encode(ds, False, True, True)) + f.write(event.encoded_dataset()) else: # We use `write_like_original=False` to ensure that a compliant # File Meta Information Header is written diff --git a/pynetdicom/dsutils.py b/pynetdicom/dsutils.py index d661a0d1cc..68eaa6af37 100644 --- a/pynetdicom/dsutils.py +++ b/pynetdicom/dsutils.py @@ -3,7 +3,6 @@ from io import BytesIO import logging from pathlib import Path -from typing import Optional, List, Tuple import zlib from pydicom import Dataset @@ -11,7 +10,7 @@ from pydicom.dataelem import DataElement from pydicom.filebase import DicomBytesIO from pydicom.filereader import read_dataset, read_preamble -from pydicom.filewriter import write_dataset +from pydicom.filewriter import write_dataset, write_file_meta_info from pydicom.tag import BaseTag from pydicom.uid import UID @@ -128,7 +127,7 @@ def decode( def encode( ds: Dataset, is_implicit_vr: bool, is_little_endian: bool, deflated: bool = False -) -> Optional[bytes]: +) -> bytes | None: """Encode a *pydicom* :class:`~pydicom.dataset.Dataset` `ds`. .. versionchanged:: 1.5 @@ -182,7 +181,21 @@ def encode( return bytestring -def pretty_dataset(ds: Dataset, indent: int = 0, indent_char: str = " ") -> List[str]: +def encode_file_meta(file_meta: FileMetaDataset) -> bytes: + """Return the encoded File Meta Information elements in `file_meta`. + + .. versionadded:: 2.1 + + """ + + buffer = DicomBytesIO() + buffer.is_little_endian = True + buffer.is_implicit_VR = False + write_file_meta_info(buffer, file_meta) + return buffer.getvalue() + + +def pretty_dataset(ds: Dataset, indent: int = 0, indent_char: str = " ") -> list[str]: """Return a list of pretty dataset strings. .. versionadded:: 1.5 @@ -270,7 +283,7 @@ def pretty_element(elem: DataElement) -> str: ) -def split_dataset(path: Path) -> Tuple[Dataset, int]: +def split_dataset(path: Path) -> tuple[Dataset, int]: """Return the file meta elements and the offset to the start of the dataset .. versionadded:: 2.0 @@ -288,7 +301,7 @@ def split_dataset(path: Path) -> Tuple[Dataset, int]: no File Meta is present. """ - def _not_group_0002(tag: BaseTag, VR: Optional[str], length: int) -> bool: + def _not_group_0002(tag: BaseTag, VR: str | None, length: int) -> bool: """Return True if the tag is not in group 0x0002, False otherwise.""" return tag.group != 2 diff --git a/pynetdicom/events.py b/pynetdicom/events.py index 28cb495f98..0d79a3a179 100644 --- a/pynetdicom/events.py +++ b/pynetdicom/events.py @@ -3,30 +3,20 @@ """ from datetime import datetime +from io import BytesIO import inspect import logging from pathlib import Path import sys -from typing import ( - Union, - Callable, - Any, - Tuple, - List, - NamedTuple, - Optional, - TYPE_CHECKING, - Dict, - cast, - Iterator, -) +from typing import Callable, Any, NamedTuple, TYPE_CHECKING, cast, Iterator, Union from pydicom.dataset import Dataset, FileMetaDataset +from pynetdicom.dimse_primitives import C_STORE from pydicom.filereader import dcmread from pydicom.tag import BaseTag from pydicom.uid import UID -from pynetdicom.dsutils import decode, create_file_meta +from pynetdicom.dsutils import decode, create_file_meta, encode_file_meta if TYPE_CHECKING: # pragma: no cover from pynetdicom.association import Association @@ -36,7 +26,6 @@ C_FIND, C_GET, C_MOVE, - C_STORE, N_ACTION, N_CREATE, N_DELETE, @@ -48,31 +37,29 @@ from pynetdicom.pdu_primitives import SOPClassCommonExtendedNegotiation from pynetdicom.presentation import PresentationContextTuple - _RequestType = Union[ - C_ECHO, - C_FIND, - C_GET, - C_MOVE, - C_STORE, - N_ACTION, - N_CREATE, - N_DELETE, - N_EVENT_REPORT, - N_GET, - N_SET, - ] + _RequestType = ( + C_ECHO + | C_FIND + | C_GET + | C_MOVE + | C_STORE + | N_ACTION + | N_CREATE + | N_DELETE + | N_EVENT_REPORT + | N_GET + | N_SET + ) LOGGER = logging.getLogger("pynetdicom.events") EventType = Union["NotificationEvent", "InterventionEvent"] -EventHandlerType = Union[ - Tuple[EventType, Callable], Tuple[EventType, Callable, List[Any]] -] -_BasicReturnType = Union[Dataset, int] -_DatasetReturnType = Tuple[_BasicReturnType, Optional[Dataset]] -_IteratorType = Iterator[Tuple[_BasicReturnType, Optional[Dataset]]] +EventHandlerType = tuple[EventType, Callable] | tuple[EventType, Callable, list[Any]] +_BasicReturnType = Dataset | int +_DatasetReturnType = tuple[_BasicReturnType, Dataset | None] +_IteratorType = Iterator[tuple[_BasicReturnType, Dataset | None]] # Notification events @@ -222,11 +209,11 @@ def __str__(self) -> str: ] -_HandlerBase = Tuple[Callable, Optional[List[Any]]] -_NotificationHandlerAttr = List[_HandlerBase] +_HandlerBase = tuple[Callable, list[Any] | None] +_NotificationHandlerAttr = list[_HandlerBase] _InterventionHandlerAttr = _HandlerBase -HandlerArgType = Union[_NotificationHandlerAttr, _InterventionHandlerAttr] -_HandlerAttr = Dict[EventType, HandlerArgType] +HandlerArgType = _NotificationHandlerAttr | _InterventionHandlerAttr +_HandlerAttr = dict[EventType, HandlerArgType] def _add_handler( @@ -239,11 +226,10 @@ def _add_handler( event : NotificationEvent or InterventionEvent The event the handler should be bound to. handlers_attr : dict - The object attribute of {event: Union[ - [(handler, Optional[args])], - (handler, Optional[args]) - ]} used to record bindings. - handler_arg : Tuple[Callable, Optional[List[Any]]] + The object attribute of + {event: [(handler, None | args)] | (handler, None | args)} used to + record bindings. + handler_arg : tuple[Callable, None | list[Any]] The handler and optional arguments to be bound. """ if isinstance(event, NotificationEvent): @@ -270,12 +256,8 @@ def _remove_handler( The event the handler should be unbound from. handlers_attr : dict The object attribute of - { - event: Union[ - List[(handler, Optional[args])], - (handler, Optional[args]) - ] - } used to record bindings. + {event: list[(handler, None | args)] | (handler, None | args)} used + to record bindings. handler_arg : Callable The handler to be unbound. """ @@ -320,8 +302,8 @@ def get_default_handler(event: InterventionEvent) -> Callable[["Event"], Any]: def trigger( - assoc: "Association", event: EventType, attrs: Optional[Dict[str, Any]] = None -) -> Optional[Any]: + assoc: "Association", event: EventType, attrs: dict[str, Any] | None = None +) -> Any | None: """Trigger an `event` and call any bound handler(s). .. versionadded:: 1.3 @@ -435,7 +417,7 @@ def __init__( self, assoc: "Association", event: EventType, - attrs: Optional[Dict[str, Any]] = None, + attrs: dict[str, Any] | None = None, ) -> None: """Create a new Event. @@ -454,8 +436,8 @@ def __init__( self.timestamp = datetime.now() # Only decode a dataset when necessary - self._hash: Optional[int] = None - self._decoded: Optional[Dataset] = None + self._hash: int | None = None + self._decoded: Dataset | None = None # Define type hints for dynamic attributes self.request: "_RequestType" @@ -503,7 +485,7 @@ def action_information(self) -> Dataset: return self._get_dataset("ActionInformation", msg) @property - def action_type(self) -> Optional[int]: + def action_type(self) -> int | None: """Return an N-ACTION request's `Action Type ID` as an :class:`int`. .. versionadded:: 1.4 @@ -528,7 +510,7 @@ def action_type(self) -> Optional[int]: ) @property - def attribute_identifiers(self) -> List[BaseTag]: + def attribute_identifiers(self) -> list[BaseTag]: """Return an N-GET request's `Attribute Identifier List` as a :class:`list` of *pydicom* :class:`~pydicom.tag.BaseTag`. @@ -644,6 +626,66 @@ def dataset_path(self) -> Path: return cast(Path, path) + def encoded_dataset(self, include_meta: bool = True) -> bytes: + """Return the encoded C-STORE dataset sent by the peer without first + decoding it. + + .. versionadded:: 2.1 + + Examples + -------- + Retrieve the encoded dataset as sent by the peer:: + + def handle_store(event: pynetdicom.events.Event) -> int: + stream: bytes = event.encoded_dataset(inclue_meta=False) + + return 0x0000 + + Write the encoded dataset to file in the DICOM File Format without + having to first decode it:: + + def handle_store(event: pynetdicom.events.Event, dst: pathlib.Path) -> int: + with dst.open("wb") as f: + f.write(event.encoded_dataset()) + + return 0x0000 + + Parameters + ---------- + include_meta : bool, optional + If ``True`` (default) then include the encoded DICOM preamble, + prefix and file meta information with the returned bytestream. + + Returns + ------- + bytes + The encoded dataset as sent by the peer, with or without the file + meta information. + + Raises + ------ + AttributeError + If the corresponding event is not a C-STORE request. + """ + try: + request = cast(C_STORE, self.request) + stream = cast(BytesIO, request.DataSet).getvalue() + except AttributeError: + raise AttributeError( + "The corresponding event is not a C-STORE request and has no " + "'Data Set' parameter" + ) + + if not include_meta: + return stream + + return b"".join(( + b"\x00" * 128, + b"DICM", + encode_file_meta(self.file_meta), + stream, + )) + @property def event(self) -> EventType: """Return the corresponding event. @@ -686,7 +728,7 @@ def event_information(self) -> Dataset: return self._get_dataset("EventInformation", msg) @property - def event_type(self) -> Optional[int]: + def event_type(self) -> int | None: """Return an N-EVENT-REPORT request's `Event Type ID` as an :class:`int`. @@ -747,7 +789,8 @@ def file_meta(self) -> FileMetaDataset: Encode the File Meta Information in a new file and append the encoded *Data Set* to it. This skips having to decode/re-encode the *Data Set* - as in the previous example. + as in the previous example (or alternatively, just use the + :meth:`~pynetdicom.events.Event.encoded_dataset` method). .. code-block:: python @@ -945,7 +988,7 @@ def modification_list(self) -> Dataset: return self._get_dataset("ModificationList", msg) @property - def move_destination(self) -> Optional[str]: + def move_destination(self) -> str | None: """Return a C-MOVE request's `Move Destination` as :class:`str`. .. versionadded:: 1.4 @@ -976,7 +1019,7 @@ def move_destination(self) -> Optional[str]: # Default extended negotiation event handlers -def _async_ops_handler(event: Event) -> Tuple[int, int]: +def _async_ops_handler(event: Event) -> tuple[int, int]: """Default handler for when an Asynchronous Operations Window Negotiation item is include in the association request. @@ -989,7 +1032,7 @@ def _async_ops_handler(event: Event) -> Tuple[int, int]: ) -def _sop_common_handler(event: Event) -> Dict[UID, "SOPClassCommonExtendedNegotiation"]: +def _sop_common_handler(event: Event) -> dict[UID, "SOPClassCommonExtendedNegotiation"]: """Default handler for when one or more SOP Class Common Extended Negotiation items are included in the association request. @@ -998,7 +1041,7 @@ def _sop_common_handler(event: Event) -> Dict[UID, "SOPClassCommonExtendedNegoti return {} -def _sop_extended_handler(event: Event) -> Dict[UID, bytes]: +def _sop_extended_handler(event: Event) -> dict[UID, bytes]: """Default handler for when one or more SOP Class Extended Negotiation items are included in the association request. @@ -1007,7 +1050,7 @@ def _sop_extended_handler(event: Event) -> Dict[UID, bytes]: return {} -def _user_identity_handler(event: Event) -> Tuple[bool, Optional[bytes]]: +def _user_identity_handler(event: Event) -> tuple[bool, bytes | None]: """Default handler for when a user identity negotiation item is included with the association request. diff --git a/pynetdicom/tests/test_events.py b/pynetdicom/tests/test_events.py index fbbeeaa89a..dbb6794bdb 100644 --- a/pynetdicom/tests/test_events.py +++ b/pynetdicom/tests/test_events.py @@ -149,6 +149,13 @@ def test_raises(self): with pytest.raises(AttributeError, match=msg): event.dataset + msg = ( + r"The corresponding event is not a C-STORE request " + r"and has no 'Data Set' parameter" + ) + with pytest.raises(AttributeError, match=msg): + event.encoded_dataset() + msg = ( r"The corresponding event is not a C-FIND, C-GET or C-MOVE request " r"and has no 'Identifier' parameter" @@ -511,6 +518,40 @@ def test_event_type(self): assert event.event_type == 2 + def test_encode_dataset(self): + """Test Event.encode_dataset()""" + request = C_STORE() + request.AffectedSOPClassUID = "1.2" + request.AffectedSOPInstanceUID = "1.3" + request.DataSet = BytesIO(b"\x00\x01") + + event = Event( + None, + evt.EVT_C_STORE, + {"request": request, "context": self.context.as_tuple}, + ) + bs = event.encoded_dataset() + + from pynetdicom.utils import pretty_bytes + + assert bs[:128] == b"\x00" * 128 + assert bs[128:132] == b"DICM" + assert bs[132:144] == b"\x02\x00\x00\x00\x55\x4c\x04\x00\x7e\x00\x00\x00" + assert bs[144:144 + 64] == ( + b"\x02\x00\x01\x00\x4f\x42\x00\x00\x02\x00\x00\x00\x00\x01" + b"\x02\x00\x02\x00\x55\x49\x04\x00\x31\x2e\x32\x00" + b"\x02\x00\x03\x00\x55\x49\x04\x00\x31\x2e\x33\x00" + b"\x02\x00\x10\x00\x55\x49\x12\x00\x31\x2e\x32\x2e\x38\x34" + b"\x30\x2e\x31\x30\x30\x30\x38\x2e\x31\x2e\x32\x00" + ) + + # Note: may not be 126 if Implementation Class and Version change + assert 128 + 4 + 12 + 126 + 2== len(bs) + + # Test without file_meta + bs = event.encoded_dataset(include_meta=False) + assert bs == b"\x00\x01" + # TODO: Should be able to remove in v1.4 INTERVENTION_HANDLERS = [