diff --git a/docs/changelog/v2.1.0.rst b/docs/changelog/v2.1.0.rst index 0a7c0cb61..558af8cd0 100644 --- a/docs/changelog/v2.1.0.rst +++ b/docs/changelog/v2.1.0.rst @@ -23,6 +23,8 @@ Enhancements :class:`~pynetdicom.service_class.QueryRetrieveServiceClass` (:issue:`878`) * Added support for :class:`Inventory Query/Retrieve Service Class ` (:issue:`879`) +* Added support for :class:`Storage Management Service Class + ` (:issue:`880`) * Added :meth:`~pynetdicom.events.Event.encoded_dataset` to simplify accessing the encoded dataset without first decoding it diff --git a/docs/index.rst b/docs/index.rst index 5e8dba473..8d2898c0c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,6 +96,7 @@ Supported Service Classes * Structured Reporting * Volumetric Presentation State * :doc:`Storage Commitment ` +* :doc:`Storage Management ` * :doc:`Substance Administration Query ` * :doc:`Unified Procedure Step ` * :doc:`Verification ` diff --git a/docs/service_classes/index.rst b/docs/service_classes/index.rst index 072e34f1a..cef53916d 100644 --- a/docs/service_classes/index.rst +++ b/docs/service_classes/index.rst @@ -25,6 +25,7 @@ Supported Service Classes rt_machine storage_service_class storage_commitment + storage_management substance_admin_service_class ups verification_service_class diff --git a/docs/service_classes/storage_management.rst b/docs/service_classes/storage_management.rst new file mode 100644 index 000000000..31a74865b --- /dev/null +++ b/docs/service_classes/storage_management.rst @@ -0,0 +1,106 @@ +.. _service_storemanage: + +Storage Management Service Class +================================ +The :dcm:`Storage Management Service Class` +defines a service that uses the DIMSE N-ACTION and N-EVENT-REPORT services to +facilitate peer-to-peer controls for the management of persistent storage of +Composite SOP Instances. + +.. _storeman_sops: + +Supported SOP Classes +--------------------- + ++-------------------------------+---------------------------------------------+ +| UID | SOP Class | ++===============================+=============================================+ +| 1.2.840.10008.5.1.4.1.1.201.5 | InventoryCreation | ++-------------------------------+---------------------------------------------+ + + +DIMSE Services +-------------- + ++-----------------+-----------------------------------------+ +| DIMSE Service | Usage SCU/SCP | ++=================+=========================================+ +| N-EVENT-REPORT | Mandatory/Mandatory | ++-----------------+-----------------------------------------+ +| N-ACTION | Mandatory/Mandatory | ++-----------------+-----------------------------------------+ + + +.. _storeman_statuses: + +Statuses +-------- + +N-ACTION Statuses +~~~~~~~~~~~~~~~~~ + ++------------------+----------+-----------------------------------------------+ +| Code (hex) | Category | Description | ++==================+==========+===============================================+ +| 0x0000 | Success | Success | ++------------------+----------+-----------------------------------------------+ +| 0x0112 | Failure | No such SOP Instance | ++------------------+----------+-----------------------------------------------+ +| 0x0114 | Failure | No such argument | ++------------------+----------+-----------------------------------------------+ +| 0x0115 | Failure | Invalid argument value | ++------------------+----------+-----------------------------------------------+ +| 0x0117 | Failure | Invalid object instance | ++------------------+----------+-----------------------------------------------+ +| 0x0118 | Failure | No such SOP Class | ++------------------+----------+-----------------------------------------------+ +| 0x0119 | Failure | Class-Instance conflict | ++------------------+----------+-----------------------------------------------+ +| 0x0123 | Failure | No such action | ++------------------+----------+-----------------------------------------------+ +| 0x0124 | Failure | Refused: not authorised | ++------------------+----------+-----------------------------------------------+ +| 0x0210 | Failure | Duplicate invocation | ++------------------+----------+-----------------------------------------------+ +| 0x0211 | Failure | Unrecognised operation | ++------------------+----------+-----------------------------------------------+ +| 0x0212 | Failure | Mistyped argument | ++------------------+----------+-----------------------------------------------+ +| 0x0213 | Failure | Resource limitation | ++------------------+----------+-----------------------------------------------+ +| 0xB010 | Warning | Attribute list error - One or more of Key | +| | | Attributes are not supported for matching | ++------------------+----------+-----------------------------------------------+ + +N-EVENT-REPORT Statuses +~~~~~~~~~~~~~~~~~~~~~~~ + ++------------------+----------+----------------------------------+ +| Code (hex) | Category | Description | ++==================+==========+==================================+ +| 0x0000 | Success | Success | ++------------------+----------+----------------------------------+ +| 0x0110 | Failure | Processing failure | ++------------------+----------+----------------------------------+ +| 0x0112 | Failure | No such SOP Instance | ++------------------+----------+----------------------------------+ +| 0x0113 | Failure | No such event type | ++------------------+----------+----------------------------------+ +| 0x0114 | Failure | No such argument | ++------------------+----------+----------------------------------+ +| 0x0115 | Failure | Invalid argument value | ++------------------+----------+----------------------------------+ +| 0x0117 | Failure | Invalid object Instance | ++------------------+----------+----------------------------------+ +| 0x0118 | Failure | No such SOP Class | ++------------------+----------+----------------------------------+ +| 0x0119 | Failure | Class-Instance conflict | ++------------------+----------+----------------------------------+ +| 0x0210 | Failure | Duplicate invocation | ++------------------+----------+----------------------------------+ +| 0x0211 | Failure | Unrecognised operation | ++------------------+----------+----------------------------------+ +| 0x0212 | Failure | Mistyped argument | ++------------------+----------+----------------------------------+ +| 0x0213 | Failure | Resource limitation | ++------------------+----------+----------------------------------+ diff --git a/pynetdicom/association.py b/pynetdicom/association.py index 6aa0fc1ed..48027cec8 100644 --- a/pynetdicom/association.py +++ b/pynetdicom/association.py @@ -2269,6 +2269,13 @@ def send_n_action( | ``0x0212`` - Mistyped argument | ``0x0213`` - Resource limitation + *Storage Management Service* specific (DICOM + Standard Part 4, Annex KK.2.2.3): + + Warning + | ``0xB010`` - Attribute list error - One or more of Key + Attributes are not supported for matching + action_reply : pydicom.dataset.Dataset or None If the status category is 'Success' or 'Warning' then a :class:`~pydicom.dataset.Dataset` containing attributes @@ -2288,6 +2295,7 @@ def send_n_action( :class:`~pynetdicom.service_class_n.PrintManagementServiceClass` :class:`~pynetdicom.service_class_n.RTMachineVerificationServiceClass` :class:`~pynetdicom.service_class_n.StorageCommitmentServiceClass` + :class:`~pynetdicom.service_class_n.StorageManagementServiceClass` :class:`~pynetdicom.service_class_n.UnifiedProcedureStepServiceClass` References @@ -2299,6 +2307,7 @@ def send_n_action( * DICOM Standard, Part 4, :dcm:`Annex S` * DICOM Standard, Part 4, :dcm:`Annex CC` * DICOM Standard, Part 4, :dcm:`Annex DD` + * DICOM Standard, Part 4, :dcm:`Annex KK` * DICOM Standard, Part 7, Sections :dcm:`10.1.4`, :dcm:`10.3.4` and @@ -2864,6 +2873,7 @@ def send_n_event_report( :class:`~pynetdicom.service_class_n.ProcedureStepServiceClass` :class:`~pynetdicom.service_class_n.RTMachineVerificationServiceClass` :class:`~pynetdicom.service_class_n.StorageCommitmentServiceClass` + :class:`~pynetdicom.service_class_n.StorageManagementServiceClass` :class:`~pynetdicom.service_class_n.UnifiedProcedureStepServiceClass` References @@ -2874,6 +2884,7 @@ def send_n_event_report( * DICOM Standard, Part 4, :dcm:`Annex J ` * DICOM Standard, Part 4, :dcm:`Annex CC ` * DICOM Standard, Part 4, :dcm:`Annex DD ` + * DICOM Standard, Part 4, :dcm:`Annex KK ` * DICOM Standard, Part 7, Sections :dcm:`10.1.1 `, :dcm:`10.3.1 ` diff --git a/pynetdicom/events.py b/pynetdicom/events.py index 0d79a3a17..e3c39471c 100644 --- a/pynetdicom/events.py +++ b/pynetdicom/events.py @@ -637,7 +637,7 @@ def encoded_dataset(self, include_meta: bool = True) -> bytes: 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) + stream: bytes = event.encoded_dataset(include_meta=False) return 0x0000 diff --git a/pynetdicom/service_class.py b/pynetdicom/service_class.py index bb0a032b4..e7af6252b 100644 --- a/pynetdicom/service_class.py +++ b/pynetdicom/service_class.py @@ -8,16 +8,12 @@ from types import TracebackType from typing import ( TYPE_CHECKING, - Optional, Type, cast, - Union, - Tuple, Any, TypeVar, Iterator, Sequence, - Dict, ) from pydicom.dataset import Dataset @@ -67,17 +63,17 @@ from pynetdicom.presentation import PresentationContext from pynetdicom.transport import AssociationSocket - _QR = Union[C_FIND, C_MOVE, C_GET] + _QR = C_FIND | C_MOVE | C_GET -StatusType = Union[int, Dataset] -DatasetType = Optional[Dataset] -UserReturnType = Tuple[StatusType, DatasetType] +StatusType = int | Dataset +DatasetType = Dataset | None +UserReturnType = tuple[StatusType, DatasetType] _T = TypeVar("_T", bound=DIMSEPrimitive) -_ExcInfoType = Union[ - Tuple[None, None, None], Tuple[Type[BaseException], BaseException, TracebackType] -] -DestinationType = Union[Tuple[str, int], Tuple[str, int, Dict[str, Any]]] +_ExcInfoType = ( + tuple[None, None, None] | tuple[Type[BaseException], BaseException, TracebackType] +) +DestinationType = tuple[str, int] | tuple[str, int, dict[str, Any]] LOGGER = logging.getLogger("pynetdicom.service-c") @@ -108,10 +104,10 @@ def __enter__(self) -> "attempt": def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> Optional[bool]: + exc_type: Type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: if exc_type is None: # No exceptions raised return None @@ -285,7 +281,7 @@ def _c_find_scp(self, req: C_FIND, context: "PresentationContext") -> None: for ii, (result, exc) in enumerate(self._wrap_handler(generator)): # Reset the response Identifier rsp.Identifier = None - dataset: Optional[Dataset] + dataset: Dataset | None rsp_status: StatusType # Exception raised by user's generator @@ -1319,7 +1315,7 @@ def SCP(self, req: Any, context: "PresentationContext") -> None: ) raise NotImplementedError(msg) - def validate_status(self, status: Union[int, Dataset], rsp: _T) -> _T: + def validate_status(self, status: int | Dataset, rsp: _T) -> _T: """Validate `status` and set `rsp.Status` accordingly. Parameters @@ -1375,7 +1371,7 @@ def validate_status(self, status: Union[int, Dataset], rsp: _T) -> _T: def _wrap_handler( self, handler: Iterator - ) -> Iterator[Union[Tuple[None, _ExcInfoType], Tuple[UserReturnType, None]]]: + ) -> Iterator[tuple[None, _ExcInfoType] | tuple[UserReturnType, None]]: """Wrap a generator handler to catch exceptions. Parameters @@ -2460,7 +2456,10 @@ class ImplantTemplateQueryRetrieveServiceClass(QueryRetrieveServiceClass): class InventoryQueryRetrieveServiceClass(QueryRetrieveServiceClass): - """Implementation of the Inventory QR Service.""" + """Implementation of the Inventory QR Service. + + .. versionadded:: 2.1 + """ pass diff --git a/pynetdicom/service_class_n.py b/pynetdicom/service_class_n.py index 30a9dd612..70c944728 100644 --- a/pynetdicom/service_class_n.py +++ b/pynetdicom/service_class_n.py @@ -1,7 +1,7 @@ """Implements the supported Service Classes that make use of DIMSE-N.""" import logging -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING from pynetdicom.dimse_primitives import ( N_ACTION, @@ -20,6 +20,7 @@ PRINT_JOB_MANAGEMENT_SERVICE_CLASS_STATUS, PROCEDURE_STEP_STATUS, STORAGE_COMMITMENT_SERVICE_CLASS_STATUS, + STORAGE_MANAGEMENT_SERVICE_CLASS_STATUS, RT_MACHINE_VERIFICATION_SERVICE_CLASS_STATUS, UNIFIED_PROCEDURE_STEP_SERVICE_CLASS_STATUS, ) @@ -27,11 +28,12 @@ if TYPE_CHECKING: # pragma: no cover from pynetdicom.presentation import PresentationContext - _MCM = Union[N_CREATE, N_GET, N_ACTION] - _PJ = Union[N_CREATE, N_EVENT_REPORT, N_GET, N_SET, N_ACTION, N_DELETE] - _PS = Union[N_CREATE, N_EVENT_REPORT, N_GET, N_SET] - _SCS = Union[N_EVENT_REPORT, N_ACTION] - _UPS = Union[N_CREATE, N_EVENT_REPORT, N_GET, N_SET, N_ACTION, C_FIND] + _MCM = N_CREATE | N_GET | N_ACTION + _PJ = N_CREATE | N_EVENT_REPORT | N_GET | N_SET | N_ACTION | N_DELETE + _PS = N_CREATE | N_EVENT_REPORT | N_GET | N_SET + _SCS = N_EVENT_REPORT | N_ACTION + _SMS = N_ACTION | N_EVENT_REPORT + _UPS = N_CREATE | N_EVENT_REPORT | N_GET | N_SET | N_ACTION | C_FIND LOGGER = logging.getLogger("pynetdicom.service-n") @@ -289,6 +291,35 @@ def SCP(self, req: "_SCS", context: "PresentationContext") -> None: ) +class StorageManagementServiceClass(ServiceClass): + """Implementation of the Storage Management Service Class + + .. versionadded:: 2.1 + """ + + statuses = STORAGE_MANAGEMENT_SERVICE_CLASS_STATUS + + def SCP(self, req: "_SMS", context: "PresentationContext") -> None: + """The SCP implementation for Storage Management Service Class. + + Parameters + ---------- + req : dimse_primitives.N_EVENT_REPORT or N_ACTION + The N-ACTION or N-EVENT-REPORT request primitive sent by the peer. + context : presentation.PresentationContext + The presentation context that the service is operating under. + """ + if isinstance(req, N_EVENT_REPORT): + self._n_event_report_scp(req, context) + elif isinstance(req, N_ACTION): + self._n_action_scp(req, context) + else: + raise ValueError( + f"Invalid DIMSE primitive '{req.__class__.__name__}' used " + f"with Storage Management" + ) + + class UnifiedProcedureStepServiceClass(ServiceClass): """Implementation of the Unified Procedure Step Service Class diff --git a/pynetdicom/sop_class.py b/pynetdicom/sop_class.py index de1a2c736..a0552144f 100644 --- a/pynetdicom/sop_class.py +++ b/pynetdicom/sop_class.py @@ -33,6 +33,7 @@ ProcedureStepServiceClass, RTMachineVerificationServiceClass, StorageCommitmentServiceClass, + StorageManagementServiceClass, UnifiedProcedureStepServiceClass, ) @@ -122,6 +123,9 @@ def uid_to_service_class(uid: str) -> Type[ServiceClass]: if uid in _STORAGE_COMMITMENT_CLASSES.values(): return StorageCommitmentServiceClass + if uid in _STORAGE_MANAGEMENT_CLASSES.values(): + return StorageManagementServiceClass + if uid in _SUBSTANCE_ADMINISTRATION_CLASSES.values(): return SubstanceAdministrationQueryServiceClass diff --git a/pynetdicom/status.py b/pynetdicom/status.py index bb138a18a..dbc204dde 100644 --- a/pynetdicom/status.py +++ b/pynetdicom/status.py @@ -1,7 +1,6 @@ """Implementation of the DIMSE Status values.""" from enum import IntEnum -from typing import Dict, Tuple from pydicom.dataset import Dataset @@ -15,7 +14,7 @@ ) -StatusDictType = Dict[int, Tuple[str, str]] +StatusDictType = dict[int, tuple[str, str]] # Non-Service Class specific statuses - PS3.7 Annex C @@ -354,6 +353,19 @@ STORAGE_COMMITMENT_SERVICE_CLASS_STATUS = GENERAL_STATUS +# Storage Management Service Class specific status code values +STORAGE_MANAGEMENT_SERVICE_CLASS_STATUS: StatusDictType = { + 0xB010: ( + STATUS_WARNING, + ( + "Attribute list error - One or more of Key Attributes are not " + "supported for matching" + ), + ), +} +STORAGE_MANAGEMENT_SERVICE_CLASS_STATUS.update(GENERAL_STATUS) + + # Application Event Logging Service Class specific status code values APPLICATION_EVENT_LOGGING_SERVICE_CLASS_STATUS: StatusDictType = { 0xB101: ( @@ -520,8 +532,8 @@ def code_to_status(code: int) -> Dataset: ds = Dataset() ds.Status = code return ds - else: - raise ValueError("'code' must be a positive integer.") + + raise ValueError("'code' must be a positive integer.") def code_to_category(code: int) -> str: @@ -532,11 +544,14 @@ def code_to_category(code: int) -> str: if isinstance(code, int) and code >= 0: if code == 0x0000: return STATUS_SUCCESS - elif code in [0xFF00, 0xFF01]: + + if code in [0xFF00, 0xFF01]: return STATUS_PENDING - elif code == 0xFE00: + + if code == 0xFE00: return STATUS_CANCEL - elif code in [ + + if code in [ 0x0105, 0x0106, 0x0110, @@ -559,20 +574,25 @@ def code_to_category(code: int) -> str: 0x0213, ]: return STATUS_FAILURE - elif code in range(0xA000, 0xB000): + + if code in range(0xA000, 0xB000): return STATUS_FAILURE - elif code in range(0xC000, 0xD000): + + if code in range(0xC000, 0xD000): return STATUS_FAILURE - elif code in [0x0107, 0x0116]: + + if code in [0x0107, 0x0116]: return STATUS_WARNING - elif code in range(0xB000, 0xC000): + + if code in range(0xB000, 0xC000): return STATUS_WARNING - elif code == 0x0001: + + if code == 0x0001: return STATUS_WARNING return STATUS_UNKNOWN - else: - raise ValueError("'code' must be a positive integer.") + + raise ValueError("'code' must be a positive integer.") class Status(IntEnum): diff --git a/pynetdicom/tests/test_service_n.py b/pynetdicom/tests/test_service_n.py index f52c2bd53..c4b290c0f 100644 --- a/pynetdicom/tests/test_service_n.py +++ b/pynetdicom/tests/test_service_n.py @@ -29,6 +29,8 @@ PrintJob, # N-EVENT-REPORT, N-GET # Storage Commitment - N-ACTION, N-EVENT-REPORT StorageCommitmentPushModel, + # Storage Management - N-ACTION, N-EVENT-REPORT + InventoryCreation, # Application Event Logging - N-ACTION ProceduralEventLogging, # Instance Availability - N-CREATE @@ -62,6 +64,8 @@ (PrintJob, "N-GET", None, None), (StorageCommitmentPushModel, "N-ACTION", None, None), (StorageCommitmentPushModel, "N-EVENT-REPORT", None, None), + (InventoryCreation, "N-ACTION", 0xB010, None), + (InventoryCreation, "N-EVENT-REPORT", None, None), (ProceduralEventLogging, "N-ACTION", 0xB101, 0xC101), (InstanceAvailabilityNotification, "N-CREATE", None, None), (MediaCreationManagement, "N-ACTION", None, 0xC201), diff --git a/pynetdicom/tests/test_sop.py b/pynetdicom/tests/test_sop.py index 632895de2..3a61848db 100644 --- a/pynetdicom/tests/test_sop.py +++ b/pynetdicom/tests/test_sop.py @@ -28,7 +28,7 @@ _INSTANCE_AVAILABILITY_CLASSES, InstanceAvailabilityNotification, _INVENTORY_CLASSES, - InventoryCreation, + InventoryFind, _MEDIA_CREATION_CLASSES, MediaCreationManagement, _MEDIA_STORAGE_CLASSES, @@ -54,7 +54,7 @@ _STORAGE_COMMITMENT_CLASSES, StorageCommitmentPushModel, _STORAGE_MANAGEMENT_CLASSES, - InventoryFind, + InventoryCreation, _SUBSTANCE_ADMINISTRATION_CLASSES, ProductCharacteristicsQuery, _UNIFIED_PROCEDURE_STEP_CLASSES, @@ -97,6 +97,7 @@ ProcedureStepServiceClass, RTMachineVerificationServiceClass, StorageCommitmentServiceClass, + StorageManagementServiceClass, UnifiedProcedureStepServiceClass, ) @@ -315,6 +316,11 @@ def test_storage_commitment_uids(self): for uid in _STORAGE_COMMITMENT_CLASSES.values(): assert uid_to_service_class(uid) == StorageCommitmentServiceClass + def test_storage_management_uids(self): + """Test that the Storage Management SOP Class UIDs work correctly.""" + for uid in _STORAGE_MANAGEMENT_CLASSES.values(): + assert uid_to_service_class(uid) == StorageManagementServiceClass + def test_substance_admin_uids(self): """Test that the Substance Administration SOP Class UIDs work correctly.""" for uid in _SUBSTANCE_ADMINISTRATION_CLASSES.values(): @@ -474,6 +480,10 @@ def test_storage_commitment_sop(self): assert StorageCommitmentPushModel == "1.2.840.10008.1.20.1" assert StorageCommitmentPushModel.service_class == StorageCommitmentServiceClass + def test_storage_management_sop(self): + assert InventoryCreation == "1.2.840.10008.5.1.4.1.1.201.5" + assert InventoryCreation.service_class == StorageManagementServiceClass + def test_substance_admin_sop(self): """Test s Substance Administration Query Service SOP Class.""" assert ProductCharacteristicsQuery == "1.2.840.10008.5.1.4.41"