From 4f60c689ecaa745db4c5dca20151fe64dcd78d43 Mon Sep 17 00:00:00 2001 From: scaramallion Date: Tue, 14 Nov 2023 12:06:29 +1100 Subject: [PATCH] Add support for repository query --- docs/changelog/v2.1.0.rst | 3 + .../query_retrieve_service_class.rst | 61 +++-- pynetdicom/association.py | 240 ++++++++++-------- pynetdicom/service_class.py | 23 +- pynetdicom/status.py | 7 +- pynetdicom/tests/test_assoc.py | 41 +++ pynetdicom/tests/test_service_qr.py | 38 +++ 7 files changed, 276 insertions(+), 137 deletions(-) diff --git a/docs/changelog/v2.1.0.rst b/docs/changelog/v2.1.0.rst index 59e531c8bf..34120218cc 100644 --- a/docs/changelog/v2.1.0.rst +++ b/docs/changelog/v2.1.0.rst @@ -18,6 +18,9 @@ Enhancements * Added :func:`~pynetdicom.sop_class.register_uid` to make registering new private and public SOP Classes easier (:issue:`799`) +* Added support for *Repository Query* to + :meth:`~pynetdicom.association.Association.send_c_find` and + :class:`~pynetdicom.service_class.QueryRetrieveServiceClass` (:issue:`878`) Changes ....... diff --git a/docs/service_classes/query_retrieve_service_class.rst b/docs/service_classes/query_retrieve_service_class.rst index e2f013b112..7daafe44e2 100644 --- a/docs/service_classes/query_retrieve_service_class.rst +++ b/docs/service_classes/query_retrieve_service_class.rst @@ -10,33 +10,35 @@ Supported SOP Classes .. _qr_find_sops: -+-----------------------------+---------------------------------------------------+ -| UID | SOP Class | -+=============================+===================================================+ -| 1.2.840.10008.5.1.4.1.2.1.1 | PatientRootQueryRetrieveInformationModelFind | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.2.1 | StudyRootQueryRetrieveInformationModelFind | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.3.1 | PatientStudyOnlyQueryRetrieveInformationModelFind | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.1.2 | PatientRootQueryRetrieveInformationModelMove | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.2.2 | StudyRootQueryRetrieveInformationModelMove | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.3.2 | PatientStudyOnlyQueryRetrieveInformationModelMove | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.4.2 | CompositeInstanceRootRetrieveMove | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.1.3 | PatientRootQueryRetrieveInformationModelGet | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.2.3 | StudyRootQueryRetrieveInformationModelGet | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.3.3 | PatientStudyOnlyQueryRetrieveInformationModelGet | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.5.3 | CompositeInstanceRetrieveWithoutBulkDataGet | -+-----------------------------+---------------------------------------------------+ -| 1.2.840.10008.5.1.4.1.2.4.3 | CompositeInstanceRootRetrieveGet | -+-----------------------------+---------------------------------------------------+ ++-------------------------------+---------------------------------------------------+ +| UID | SOP Class | ++===============================+===================================================+ +| 1.2.840.10008.5.1.4.1.2.1.1 | PatientRootQueryRetrieveInformationModelFind | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.2.1 | StudyRootQueryRetrieveInformationModelFind | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.3.1 | PatientStudyOnlyQueryRetrieveInformationModelFind | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.1.2 | PatientRootQueryRetrieveInformationModelMove | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.2.2 | StudyRootQueryRetrieveInformationModelMove | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.3.2 | PatientStudyOnlyQueryRetrieveInformationModelMove | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.4.2 | CompositeInstanceRootRetrieveMove | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.1.3 | PatientRootQueryRetrieveInformationModelGet | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.2.3 | StudyRootQueryRetrieveInformationModelGet | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.3.3 | PatientStudyOnlyQueryRetrieveInformationModelGet | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.5.3 | CompositeInstanceRetrieveWithoutBulkDataGet | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.2.4.3 | CompositeInstanceRootRetrieveGet | ++-------------------------------+---------------------------------------------------+ +| 1.2.840.10008.5.1.4.1.1.201.6 | RepositoryQuery | ++-------------------------------+---------------------------------------------------+ DIMSE Services -------------- @@ -111,8 +113,13 @@ Query/Retrieve (Find) Service Statuses +==================+==========+==============================================+ | 0xA700 | Failure | Out of resources | +------------------+----------+----------------------------------------------+ +| 0xA710 | Failure | Invalid prior record key | ++------------------+----------+----------------------------------------------+ | 0xA900 | Failure | Dataset does not match SOP Class | +------------------+----------+----------------------------------------------+ +| 0xB001 | Warning | Matching reached response limit, subsequent | +| | | request may return additional matches | ++------------------+----------+----------------------------------------------+ | 0xC000 to 0xCFFF | Failure | Unable to process | +------------------+----------+----------------------------------------------+ | 0xFF00 | Pending | Matches are continuing | diff --git a/pynetdicom/association.py b/pynetdicom/association.py index a2b4f939b3..86709a1dc5 100644 --- a/pynetdicom/association.py +++ b/pynetdicom/association.py @@ -7,14 +7,9 @@ import threading import time from typing import ( - Union, - Optional, - List, Callable, Any, - Dict, Iterator, - Tuple, TYPE_CHECKING, cast, ) @@ -77,13 +72,14 @@ ) from pynetdicom.presentation import PresentationContext from pynetdicom.sop_class import ( # type: ignore + RepositoryQuery, uid_to_service_class, - Verification, UnifiedProcedureStepPull, UnifiedProcedureStepPush, - UnifiedProcedureStepWatch, UnifiedProcedureStepEvent, UnifiedProcedureStepQuery, + UnifiedProcedureStepWatch, + Verification, ) from pynetdicom.status import code_to_category, STORAGE_SERVICE_CLASS_STATUS from pynetdicom.utils import make_target, set_timer_resolution, set_ae, decode_bytes @@ -95,6 +91,13 @@ # pylint: enable=no-name-in-module LOGGER = logging.getLogger("pynetdicom.assoc") +HandlerType = dict[ + evt.EventType, + ( + list[tuple[Callable, None | list[Any]]] | + tuple[Callable, None | list[Any]] + ), +] class Association(threading.Thread): @@ -145,7 +148,7 @@ def __init__(self, ae: "ApplicationEntity", mode: str) -> None: # If acceptor this is the parent AssociationServer, used to identify # the thread when updating bound event-handlers - self._server: Optional["AssociationServer"] = None + self._server: None | "AssociationServer" = None # Represents the association requestor and acceptor users self.requestor: ServiceUser = ServiceUser(self, MODE_REQUESTOR) @@ -161,8 +164,8 @@ def __init__(self, ae: "ApplicationEntity", mode: str) -> None: self._sent_abort: bool = False # Accepted and rejected presentation contexts - self._accepted_cx: Dict[int, PresentationContext] = {} - self._rejected_cx: List[PresentationContext] = [] + self._accepted_cx: dict[int, PresentationContext] = {} + self._rejected_cx: list[PresentationContext] = [] # Service providers self.acse: ACSE = ACSE(self) @@ -170,22 +173,16 @@ def __init__(self, ae: "ApplicationEntity", mode: str) -> None: self.dimse: DIMSEServiceProvider = DIMSEServiceProvider(self) # Timeouts (in seconds), needs to be set after DUL init - self.acse_timeout: Optional[float] = self.ae.acse_timeout - self.connection_timeout: Optional[float] = self.ae.connection_timeout - self.dimse_timeout: Optional[float] = self.ae.dimse_timeout - self.network_timeout: Optional[float] = self.ae.network_timeout + self.acse_timeout: float | None = self.ae.acse_timeout + self.connection_timeout: float | None = self.ae.connection_timeout + self.dimse_timeout: float | None = self.ae.dimse_timeout + self.network_timeout: float | None = self.ae.network_timeout # Allow customising the response to a network timeout self.network_timeout_response = "A-ABORT" # Event handlers - self._handlers: Dict[ - evt.EventType, - Union[ - List[Tuple[Callable, Optional[List[Any]]]], - Tuple[Callable, Optional[List[Any]]], - ], - ] = {} + self._handlers: HandlerType = {} self._bind_defaults() # Kills the thread loop in run() @@ -201,7 +198,7 @@ def __init__(self, ae: "ApplicationEntity", mode: str) -> None: self._is_paused: bool = False # Windows timer resolution - self._timer_resolution: Optional[float] = _config.WINDOWS_TIMER_RESOLUTION + self._timer_resolution: float | None = _config.WINDOWS_TIMER_RESOLUTION # Thread setup threading.Thread.__init__(self, target=make_target(self.run_reactor)) @@ -231,19 +228,19 @@ def abort(self) -> None: time.sleep(0.1) @property - def accepted_contexts(self) -> List[PresentationContext]: + def accepted_contexts(self) -> list[PresentationContext]: """Return a :class:`list` of accepted :class:`~pynetdicom.presentation.PresentationContext` items.""" # Accepted contexts are stored internally as {context ID : context} return sorted(self._accepted_cx.values(), key=lambda x: cast(int, x.context_id)) @property - def acse_timeout(self) -> Optional[float]: + def acse_timeout(self) -> float | None: """The ACSE timeout (in seconds).""" return self._acse_timeout @acse_timeout.setter - def acse_timeout(self, value: Optional[float]) -> None: + def acse_timeout(self, value: float | None) -> None: """Set the ACSE timeout using numeric or ``None``.""" with self.lock: self.dul.artim_timer.timeout = value @@ -255,7 +252,7 @@ def ae(self) -> "ApplicationEntity": return self._ae def bind( - self, event: evt.EventType, handler: Callable, args: Optional[List[Any]] = None + self, event: evt.EventType, handler: Callable, args: None | list[Any] = None ) -> None: """Bind a callable `handler` to an `event`. @@ -329,17 +326,17 @@ def _check_received_status(self, rsp: DimseServiceType) -> Dataset: return status @property - def dimse_timeout(self) -> Union[int, float, None]: + def dimse_timeout(self) -> int | float | None: """The DIMSE timeout (in seconds).""" return self._dimse_timeout @dimse_timeout.setter - def dimse_timeout(self, value: Union[int, float, None]) -> None: + def dimse_timeout(self, value: int | float | None) -> None: """Set the DIMSE timeout using numeric or ``None``.""" with self.lock: self._dimse_timeout = value - def get_events(self) -> List[evt.EventType]: + def get_events(self) -> list[evt.EventType]: """Return a :class:`list` of currently bound events. .. versionadded:: 1.3 @@ -377,10 +374,10 @@ def get_handlers(self, event: evt.EventType) -> evt.HandlerArgType: def _get_valid_context( self, - ab_syntax: Union[str, UID], - tr_syntax: Union[str, UID], - role: Optional[str] = None, - context_id: Optional[int] = None, + ab_syntax: str | UID, + tr_syntax: str | UID, + role: str | None = None, + context_id: int | None = None, allow_conversion: bool = True, ) -> PresentationContext: """Return a valid presentation context matching the parameters. @@ -529,7 +526,7 @@ def kill(self) -> None: time.sleep(0.01) @property - def local(self) -> Dict[str, Any]: + def local(self) -> dict[str, Any]: """Return a :class:`dict` with information about the local AE.""" if self.is_acceptor: return self.acceptor.info @@ -571,19 +568,19 @@ def mode(self, mode: str) -> None: self._mode = mode @property - def network_timeout(self) -> Union[int, float, None]: + def network_timeout(self) -> int | float | None: """The network timeout (in seconds).""" return self._network_timeout @network_timeout.setter - def network_timeout(self, value: Union[int, float, None]) -> None: + def network_timeout(self, value: int | float | None) -> None: """Set the network timeout using numeric or ``None``.""" with self.lock: self.dul._idle_timer.timeout = value self._network_timeout = value @property - def rejected_contexts(self) -> List[PresentationContext]: + def rejected_contexts(self) -> list[PresentationContext]: """Return a :class:`list` of rejected :class:`~pynetdicom.presentation.PresentationContext`. """ @@ -603,7 +600,7 @@ def release(self) -> None: self._reactor_checkpoint.set() @property - def remote(self) -> Dict[str, Any]: + def remote(self) -> dict[str, Any]: """Return a :class:`dict` with information about the peer AE.""" if self.is_acceptor: return self.requestor.info @@ -895,8 +892,8 @@ def _c_store_scp(self, req: C_STORE) -> None: def send_c_cancel( self, msg_id: int, - context_id: Optional[int] = None, - query_model: Optional[Union[str, UID]] = None, + context_id: int | None = None, + query_model: str | UID | None = None, ) -> None: """Send a C-CANCEL request to the peer AE. @@ -1048,10 +1045,10 @@ def send_c_echo(self, msg_id: int = 1) -> Dataset: def send_c_find( self, dataset: Dataset, - query_model: Union[str, UID], + query_model: str | UID, msg_id: int = 1, priority: int = 2, - ) -> Iterator[Tuple[Dataset, Optional[Dataset]]]: + ) -> Iterator[tuple[Dataset, Dataset | None]]: """Send a C-FIND request to the peer AE. Yields (*status*, *identifier*) pairs for each response from the peer. @@ -1060,6 +1057,10 @@ def send_c_find( `query_model` now only accepts a UID string + .. versionchanged:: 2.1 + + Added support for *Repository Query* + Parameters ---------- dataset : pydicom.dataset.Dataset @@ -1132,6 +1133,16 @@ def send_c_find( Optional Keys were not supported for existence and/or matching for this Identifier) + *Query/Retrieve Service - Repository Query* specific (DICOM Standard, + Part 5, Annex C.6.4): + + Warning + | ``0xB001`` - Matching reached response limit, subsequent + request may return additional matches + + Failure + | ``0xA710`` - Invalid prior record key + *Relevant Patient Information Query Service* specific (DICOM Standard Part 4, Annex Q.2.1.1.4): @@ -1212,6 +1223,8 @@ def send_c_find( " Abstract Syntax: =" f"{cast(UID, context.abstract_syntax).name}" ) + query_model = UID(query_model) + # Build C-FIND request primitive # (M) Message ID # (M) Affected SOP Class UID @@ -1219,7 +1232,7 @@ def send_c_find( # (M) Identifier req = C_FIND() req.MessageID = msg_id - req.AffectedSOPClassUID = UID(query_model) + req.AffectedSOPClassUID = query_model req.Priority = priority # Encode the Identifier `dataset` using the agreed transfer syntax @@ -1257,15 +1270,15 @@ def send_c_find( # Wrap the generator so the C-FIND-RQ is sent immediately on # executing this function, otherwise sending C-CANCEL requests # may end up being sent first unless next() is called - return self._wrap_find_responses(transfer_syntax) + return self._wrap_find_responses(transfer_syntax, query_model) def send_c_get( self, dataset: Dataset, - query_model: Union[str, UID], + query_model: str | UID, msg_id: int = 1, priority: int = 2, - ) -> Iterator[Tuple[Dataset, Optional[Dataset]]]: + ) -> Iterator[tuple[Dataset, Dataset | None]]: """Send a C-GET request to the peer AE. Yields (*status*, *identifier*) pairs for each response from the peer. @@ -1467,10 +1480,10 @@ def send_c_move( self, dataset: Dataset, move_aet: str, - query_model: Union[str, UID], + query_model: str | UID, msg_id: int = 1, priority: int = 2, - ) -> Iterator[Tuple[Dataset, Optional[Dataset]]]: + ) -> Iterator[tuple[Dataset, Dataset | None]]: """Send a C-MOVE request to the peer AE. Yields (*status*, *identifier*) pairs for each response from the peer. @@ -1672,11 +1685,11 @@ def send_c_move( def send_c_store( self, - dataset: Union[str, Path, Dataset], + dataset: str | Path | Dataset, msg_id: int = 1, priority: int = 2, - originator_aet: Optional[str] = None, - originator_id: Optional[int] = None, + originator_aet: str | None = None, + originator_id: int | None = None, ) -> Dataset: """Send a C-STORE request to the peer AE. @@ -1912,8 +1925,8 @@ def send_c_store( return status def _wrap_find_responses( - self, transfer_syntax: UID - ) -> Iterator[Tuple[Dataset, Optional[Dataset]]]: + self, transfer_syntax: UID, query_model: UID, + ) -> Iterator[tuple[Dataset, None | Dataset]]: """Wrapper for the C-FIND response generator. Wrapping the response generators allows us to immediately send the @@ -1926,6 +1939,10 @@ def _wrap_find_responses( ---------- transfer_syntax : pydicom.uid.UID The transfer syntax UID used to encode the responses. + query_model : pydicom.uid.UID + The value to use for the C-FIND request's (0000,0002) *Affected + SOP Class UID* parameter, which usually corresponds to the + Information Model that is to be used. Yields ------ @@ -1975,6 +1992,17 @@ def _wrap_find_responses( category = code_to_category(cast(int, status.Status)) LOGGER.debug("") + if query_model == RepositoryQuery and status.Status == 0xB001: + # PS3.4, Annex C.6.4.4 + # 0xB001 conveys end of Pending responses + LOGGER.info( + f"Find SCP Response: {operation_no} - " + "0xB001 (Warning - Matching reached response limit, " + "subsequent request may return additional matches)" + ) + yield status, None + continue + if category == STATUS_PENDING: LOGGER.info( f"Find SCP Response: {operation_no} - " @@ -1986,7 +2014,7 @@ def _wrap_find_responses( # 'Success', 'Warning', 'Failure', 'Cancel' are final yields, # 'Pending' means more to come identifier = None - if category in [STATUS_PENDING]: + if category == STATUS_PENDING: operation_no += 1 with self.lock: @@ -2022,7 +2050,7 @@ def _wrap_find_responses( def _wrap_get_move_responses( self, transfer_syntax: UID - ) -> Iterator[Tuple[Dataset, Optional[Dataset]]]: + ) -> Iterator[tuple[Dataset, Dataset | None]]: """Wrapper for the C-GET/C-MOVE response generators. Wrapping the response generators allows us to immediately send the @@ -2165,11 +2193,11 @@ def send_n_action( self, dataset: Dataset, action_type: int, - class_uid: Union[str, UID], - instance_uid: Union[str, UID], + class_uid: str | UID, + instance_uid: str | UID, msg_id: int = 1, - meta_uid: Optional[Union[str, UID]] = None, - ) -> Tuple[Dataset, Optional[Dataset]]: + meta_uid: str | UID | None = None, + ) -> tuple[Dataset, Dataset | None]: """Send an N-ACTION request to the peer AE. .. versionchanged:: 1.4 @@ -2372,11 +2400,11 @@ def send_n_action( def send_n_create( self, dataset: Dataset, - class_uid: Union[str, UID], - instance_uid: Optional[Union[str, UID]] = None, + class_uid: str | UID, + instance_uid: str | UID | None = None, msg_id: int = 1, - meta_uid: Optional[Union[str, UID]] = None, - ) -> Tuple[Dataset, Optional[Dataset]]: + meta_uid: str | UID | None = None, + ) -> tuple[Dataset, Dataset | None]: """Send an N-CREATE request to the peer AE. .. versionchanged:: 1.4 @@ -2616,10 +2644,10 @@ def send_n_create( def send_n_delete( self, - class_uid: Union[str, UID], - instance_uid: Union[str, UID], + class_uid: str | UID, + instance_uid: str | UID, msg_id: int = 1, - meta_uid: Optional[Union[str, UID]] = None, + meta_uid: str | UID | None = None, ) -> Dataset: """Send an N-DELETE request to the peer AE. @@ -2742,11 +2770,11 @@ def send_n_event_report( self, dataset: Dataset, event_type: int, - class_uid: Union[str, UID], - instance_uid: Union[str, UID], + class_uid: str | UID, + instance_uid: str | UID, msg_id: int = 1, - meta_uid: Optional[Union[str, UID]] = None, - ) -> Tuple[Dataset, Optional[Dataset]]: + meta_uid: str | UID | None = None, + ) -> tuple[Dataset, Dataset | None]: """Send an N-EVENT-REPORT request to the peer AE. .. versionchanged:: 1.4 @@ -2951,12 +2979,12 @@ def send_n_event_report( def send_n_get( self, - identifier_list: List[BaseTag], - class_uid: Union[str, UID], - instance_uid: Union[str, UID], + identifier_list: list[BaseTag], + class_uid: str | UID, + instance_uid: str | UID, msg_id: int = 1, - meta_uid: Optional[Union[str, UID]] = None, - ) -> Tuple[Dataset, Optional[Dataset]]: + meta_uid: str | UID | None = None, + ) -> tuple[Dataset, Dataset | None]: """Send an N-GET request to the peer AE. .. versionchanged:: 1.4 @@ -3166,11 +3194,11 @@ def send_n_get( def send_n_set( self, dataset: Dataset, - class_uid: Union[str, UID], - instance_uid: Union[str, UID], + class_uid: str | UID, + instance_uid: str | UID, msg_id: int = 1, - meta_uid: Optional[Union[str, UID]] = None, - ) -> Tuple[Dataset, Optional[Dataset]]: + meta_uid: str | UID | None = None, + ) -> tuple[Dataset, Dataset | None]: """Send an N-SET request to the peer AE. .. versionchanged:: 1.4 @@ -3436,7 +3464,7 @@ def _serve_request(self, msg: DimseServiceType, context_id: int) -> None: # Use the Message's Affected SOP Class UID or Requested SOP # Class UID to determine which service to use - class_uid: Union[str, UID] = "" + class_uid: str | UID = "" if getattr(msg, "AffectedSOPClassUID", None) is not None: # DIMSE-C, N-EVENT-REPORT, N-CREATE use AffectedSOPClassUID class_uid = cast(UID, msg.AffectedSOPClassUID) @@ -3560,30 +3588,30 @@ def __init__(self, assoc: Association, mode: str) -> None: self.assoc: Association = assoc self._mode: str = mode self._ae_title: str = "" - self.primitive: Optional[A_ASSOCIATE] = None - self.port: Optional[int] = None - self.address: Optional[str] = "" + self.primitive: A_ASSOCIATE | None = None + self.port: int | None = None + self.address: str | None = "" # If Requestor this is the requested contexts, otherwise this is # the supported contexts - self._contexts: List[PresentationContext] = [] + self._contexts: list[PresentationContext] = [] # User Information items - self._user_info: List[_UI] = [] + self._user_info: list[_UI] = [] # Must always be set self.maximum_length: int = DEFAULT_MAX_LENGTH self.implementation_class_uid: UID = assoc.ae.implementation_class_uid # These are the proposed extended negotiation items, - self._ext_neg: Dict[_UITypes, List[_UI]] = {} + self._ext_neg: dict[_UITypes, list[_UI]] = {} self.reset_negotiation_items() # If Acceptor then this the accepted SOP Class Common Extended # negotiation items - self._common_ext: Dict[UID, SOPClassCommonExtendedNegotiation] = {} + self._common_ext: dict[UID, SOPClassCommonExtendedNegotiation] = {} @property - def accepted_common_extended(self) -> Dict[UID, Tuple[UID, List[UID]]]: + def accepted_common_extended(self) -> dict[UID, tuple[UID, list[UID]]]: """Return a :class:`dict` of the accepted SOP Class Common Extended Negotiation. @@ -3680,7 +3708,7 @@ def ae_title(self, value: str) -> None: self._ae_title = cast(str, set_ae(value, "ae_title", False, False)) @property - def asynchronous_operations(self) -> Tuple[int, int]: + def asynchronous_operations(self) -> tuple[int, int]: """Return the Asynchronous Operations Window operations numbers. Returns @@ -3708,7 +3736,7 @@ def asynchronous_operations(self) -> Tuple[int, int]: return (1, 1) @property - def extended_negotiation(self) -> List[_UI]: + def extended_negotiation(self) -> list[_UI]: """Return a :class:`list` of Extended Negotiation items. Extended Negotiation items are: @@ -3741,7 +3769,7 @@ def extended_negotiation(self) -> List[_UI]: return items - def get_contexts(self, cx_type: str) -> List[PresentationContext]: + def get_contexts(self, cx_type: str) -> list[PresentationContext]: """Return a :class:`list` of :class:`~pynetdicom.presentation.PresentationContext` items corresponding to `cx_type`. @@ -3786,7 +3814,7 @@ def get_contexts(self, cx_type: str) -> List[PresentationContext]: } ) - possible: Dict[bool, Dict[bool, Dict[bool, List[str]]]] = { + possible: dict[bool, dict[bool, dict[bool, list[str]]]] = { True: { # self.assoc.is_requestor True: { # self.writeable True: ["requested"], # self.is_requestor @@ -3819,7 +3847,7 @@ def get_contexts(self, cx_type: str) -> List[PresentationContext]: ) @property - def implementation_class_uid(self) -> Optional[UID]: + def implementation_class_uid(self) -> UID | None: """The Implementation Class UID as a :class:`~pydicom.uid.UID`. Returns @@ -3867,7 +3895,7 @@ def implementation_class_uid(self, value: UID) -> None: self._user_info.append(item) @property - def implementation_version_name(self) -> Optional[str]: + def implementation_version_name(self) -> str | None: """Get or set the *Implementation Version Name*. Parameters @@ -3900,7 +3928,7 @@ def implementation_version_name(self) -> Optional[str]: return None @implementation_version_name.setter - def implementation_version_name(self, value: Optional[str]) -> None: + def implementation_version_name(self, value: str | None) -> None: """Set the Implementation Version Name (only prior to association).""" if not self.writeable: raise RuntimeError( @@ -3929,7 +3957,7 @@ def implementation_version_name(self, value: Optional[str]) -> None: self._user_info.append(item) @property - def info(self) -> Dict[str, Any]: + def info(self) -> dict[str, Any]: """Return a :class:`dict` with information about the :class:`ServiceUser`. """ @@ -3959,7 +3987,7 @@ def is_requestor(self) -> bool: return self.mode == MODE_REQUESTOR @property - def maximum_length(self) -> Optional[int]: + def maximum_length(self) -> int | None: """The maximum PDV size as :class:`int`. Returns @@ -4013,7 +4041,7 @@ def mode(self) -> str: return self._mode @property - def requested_contexts(self) -> List[PresentationContext]: + def requested_contexts(self) -> list[PresentationContext]: """A :class:`list` of the requestor's requested presentation contexts. """ @@ -4023,7 +4051,7 @@ def requested_contexts(self) -> List[PresentationContext]: return self.get_contexts("requested") @requested_contexts.setter - def requested_contexts(self, value: List[PresentationContext]) -> None: + def requested_contexts(self, value: list[PresentationContext]) -> None: """Set the requested presentation contexts. Parameters @@ -4121,7 +4149,7 @@ def reset_negotiation_items(self) -> None: self._ext_neg[SOPClassCommonExtendedNegotiation] = [] @property - def role_selection(self) -> Dict[UID, SCP_SCU_RoleSelectionNegotiation]: + def role_selection(self) -> dict[UID, SCP_SCU_RoleSelectionNegotiation]: """Return any SCP/SCU Role Selection items. Returns @@ -4147,7 +4175,7 @@ def role_selection(self) -> Dict[UID, SCP_SCU_RoleSelectionNegotiation]: @property def sop_class_common_extended( self, - ) -> Dict[UID, SOPClassCommonExtendedNegotiation]: + ) -> dict[UID, SOPClassCommonExtendedNegotiation]: """Return the SOP Class Common Extended items. If the :class:`ServiceUser` is the association acceptor then no SOP @@ -4177,7 +4205,7 @@ def sop_class_common_extended( return sop_classes @property - def sop_class_extended(self) -> Dict[UID, bytes]: + def sop_class_extended(self) -> dict[UID, bytes]: """Return any SOP Class Extended items. Returns @@ -4205,7 +4233,7 @@ def sop_class_extended(self) -> Dict[UID, bytes]: return sop_classes @property - def supported_contexts(self) -> List[PresentationContext]: + def supported_contexts(self) -> list[PresentationContext]: """The supported presentation contexts. Returns @@ -4219,7 +4247,7 @@ def supported_contexts(self) -> List[PresentationContext]: return self.get_contexts("supported") @supported_contexts.setter - def supported_contexts(self, value: List[PresentationContext]) -> None: + def supported_contexts(self, value: list[PresentationContext]) -> None: """Set the supported presentation contexts. Parameters @@ -4249,7 +4277,7 @@ def supported_contexts(self, value: List[PresentationContext]) -> None: self._contexts = value @property - def user_identity(self) -> Optional[UserIdentityNegotiation]: + def user_identity(self) -> UserIdentityNegotiation | None: """Return the User Identity Negotiation Item (if available). Returns @@ -4272,7 +4300,7 @@ def user_identity(self) -> Optional[UserIdentityNegotiation]: return None @property - def user_information(self) -> List[_UI]: + def user_information(self) -> list[_UI]: """Returns a :class:`list` of the User Information items.""" if not self.writeable: return cast(A_ASSOCIATE, self.primitive).user_information diff --git a/pynetdicom/service_class.py b/pynetdicom/service_class.py index 03be1edf30..8b0a4a02e4 100644 --- a/pynetdicom/service_class.py +++ b/pynetdicom/service_class.py @@ -198,12 +198,17 @@ def _c_find_scp(self, req: C_FIND, context: "PresentationContext") -> None: | ``0xFF00`` Matches are continuing, current match supplied | ``0xFF01`` Matches are continuing, warning + Warning + | ``0xB001`` Matching reached response limit, subsequent request may + return additional matches + Cancel | ``0xFE00`` Cancel Failure | ``0x0122`` SOP class not supported | ``0xA700`` Out of resources + | ``0xA710`` Invalid prior record key | ``0xA900`` Identifier does not match SOP class | ``0xC000`` to ``0xCFFF`` Unable to process @@ -316,7 +321,8 @@ def _c_find_scp(self, req: C_FIND, context: "PresentationContext") -> None: LOGGER.info(f"Find SCP Response {ii + 1}: 0x{rsp.Status:04X} (Cancel)") self.dimse.send_msg(rsp, cx_id) return - elif status[0] == STATUS_FAILURE: + + if status[0] == STATUS_FAILURE: # If failed, then dataset is None LOGGER.info( f"Find SCP Response {ii + 1}: 0x{rsp.Status:04X} " @@ -324,13 +330,23 @@ def _c_find_scp(self, req: C_FIND, context: "PresentationContext") -> None: ) self.dimse.send_msg(rsp, cx_id) return - elif status[0] == STATUS_SUCCESS: + + if status[0] == STATUS_SUCCESS: # User isn't supposed to send these, but handle anyway # If success, then dataset is None LOGGER.info(f"Find SCP Response {ii + 1}: 0x0000 (Success)") self.dimse.send_msg(rsp, cx_id) return - elif status[0] == STATUS_PENDING: + + if status[0] == STATUS_WARNING: + LOGGER.info( + f"Find SCP Response {ii + 1}: 0x{rsp.Status:04X} " + f"(Warning - {status[1]})" + ) + self.dimse.send_msg(rsp, cx_id) + continue + + if status[0] == STATUS_PENDING: # If pending, `dataset` is the Identifier dataset = cast(Dataset, dataset) enc = encode( @@ -1550,6 +1566,7 @@ class QueryRetrieveServiceClass(ServiceClass): "1.2.840.10008.5.1.4.44.2", "1.2.840.10008.5.1.4.45.2", "1.2.840.10008.5.1.4.1.1.200.4", + "1.2.840.10008.5.1.4.1.1.201.6", ], "C-GET": [ "1.2.840.10008.5.1.4.1.2.1.3", diff --git a/pynetdicom/status.py b/pynetdicom/status.py index f5b32ec64b..c7ba3cbba2 100644 --- a/pynetdicom/status.py +++ b/pynetdicom/status.py @@ -75,7 +75,7 @@ # Query Retrieve - FIND specific status code values -# PS3.4 Annex C.4.1.1.4 +# PS3.4 Annex C.4.1.1.4 and Annex C.6.4.3 # Hanging Protocol - FIND specific status code values # PS3.4 Annex U.4.1 # Color Palette - FIND specific status code values @@ -86,7 +86,12 @@ # PS3.4 Annex HH QR_FIND_SERVICE_CLASS_STATUS: StatusDictType = { 0xA700: (STATUS_FAILURE, "Refused: Out of Resources"), + 0xA710: (STATUS_FAILURE, "Invalid Prior Record Key"), 0xA900: (STATUS_FAILURE, "Identifier Does Not Match SOP Class"), + 0xB001: ( + STATUS_WARNING, + "Matching reached response limit, subsequent request may return additional matches" + ), 0xFF00: (STATUS_PENDING, "Matches are continuing, current match supplied"), 0xFF01: (STATUS_PENDING, "Matches are continuing, warning"), } diff --git a/pynetdicom/tests/test_assoc.py b/pynetdicom/tests/test_assoc.py index 283e112948..f635648ef9 100644 --- a/pynetdicom/tests/test_assoc.py +++ b/pynetdicom/tests/test_assoc.py @@ -64,6 +64,7 @@ SecondaryCaptureImageStorage, UnifiedProcedureStepPull, UnifiedProcedureStepPush, + RepositoryQuery, ) from .hide_modules import hide_modules @@ -2497,6 +2498,46 @@ def handle(event): ) assert msg in caplog.text + def test_repository_query(self, caplog): + """Test receiving a success response from the peer""" + + def handle(event): + yield 0xB001, None + yield 0x0000, None + + self.ae = ae = AE() + ae.acse_timeout = 5 + ae.dimse_timeout = 5 + ae.network_timeout = 5 + ae.add_supported_context(RepositoryQuery) + scp = ae.start_server( + ("localhost", 11112), block=False, evt_handlers=[(evt.EVT_C_FIND, handle)] + ) + + ae.add_requested_context(RepositoryQuery) + assoc = ae.associate("localhost", 11112) + assert assoc.is_established + + with caplog.at_level(logging.INFO, logger="pynetdicom"): + responses = assoc.send_c_find(self.ds, RepositoryQuery) + status, ds = next(responses) + assert status.Status == 0xB001 + assert ds is None + status, ds = next(responses) + assert status.Status == 0x0000 + assert ds is None + + assoc.release() + assert assoc.is_released + + scp.shutdown() + + msg = ( + f"Find SCP Response: 1 - 0xB001 (Warning - Matching reached " + "response limit, subsequent request may return additional matches)" + ) + assert msg in caplog.text + class TestAssociationSendCCancel: """Run tests on Association send_c_cancel.""" diff --git a/pynetdicom/tests/test_service_qr.py b/pynetdicom/tests/test_service_qr.py index 6cbf82ebcb..fa25da6aef 100644 --- a/pynetdicom/tests/test_service_qr.py +++ b/pynetdicom/tests/test_service_qr.py @@ -42,6 +42,7 @@ PatientRootQueryRetrieveInformationModelGet, PatientRootQueryRetrieveInformationModelMove, CompositeInstanceRetrieveWithoutBulkDataGet, + RepositoryQuery, _QR_CLASSES, _BASIC_WORKLIST_CLASSES, ) @@ -1227,6 +1228,43 @@ def handle(event): assert assoc.is_released scp.shutdown() + def test_repository_query(self): + """Test handler yielding pending then success status""" + + def handle(event): + yield 0xFF01, self.query + yield 0xB001, None + yield 0x0000, None + + handlers = [(evt.EVT_C_FIND, handle)] + + self.ae = ae = AE() + ae.add_supported_context(RepositoryQuery) + ae.add_requested_context(RepositoryQuery, ExplicitVRLittleEndian) + scp = ae.start_server(("localhost", 11112), block=False, evt_handlers=handlers) + + ae.acse_timeout = 5 + ae.dimse_timeout = 5 + assoc = ae.associate("localhost", 11112) + assert assoc.is_established + result = assoc.send_c_find(self.query, RepositoryQuery) + + status, identifier = next(result) + assert status.Status == 0xFF01 + assert identifier == self.query + status, identifier = next(result) + assert status.Status == 0xB001 + assert identifier == None + status, identifier = next(result) + assert status.Status == 0x0000 + assert identifier is None + with pytest.raises(StopIteration): + next(result) + + assoc.release() + assert assoc.is_released + scp.shutdown() + @pytest.fixture() def register_new_uid_get():