Repository: pydicom/pynetdicom

Issue:
Exception raised converting A-ASSOCIATE-AC PDU to A-ASSOCIATE primitive
Hi, I know an issue similar to this has been discussed but it did not help.

For test purposes, I want to make a C-MOVE request to ONIS Dicom Server to store ct and mammography images. I added my AE-Title, ip and port to ONIS so it recognizes me. I propose PatientRootQueryRetrieveInformationModelMove context and support CTImageStorage and DigitalMammographyXRayImagePresentationStorage within my AE. However I get the following debug logs that says PatientRootQueryRetrieveInformationModelMove is rejected. In its dicom conformance statement, it is written that ONIS supports PatientRootQueryRetrieveInformationModelMove.

If I don't request PatientRootQueryRetrieveInformationModelMove but VerificationPresentationContexts, association establishes successfully but I can't make a C-MOVE obviously.

Logs:

> INFO:root:Server started.
> INFO:pynetdicom.assoc:Requesting Association
> DEBUG:pynetdicom.events:Request Parameters:
> DEBUG:pynetdicom.events:========================= BEGIN A-ASSOCIATE-RQ PDU =========================
> DEBUG:pynetdicom.events:Our Implementation Class UID:      1.2.826.0.1.3680043.9.3811.1.3.1
> DEBUG:pynetdicom.events:Our Implementation Version Name:   PYNETDICOM_131
> DEBUG:pynetdicom.events:Application Context Name:    1.2.840.10008.3.1.1.1
> DEBUG:pynetdicom.events:Calling Application Name:    EGE-AI
> DEBUG:pynetdicom.events:Called Application Name:     ONIS25
> DEBUG:pynetdicom.events:Our Max PDU Receive Size:    16382
> DEBUG:pynetdicom.events:Presentation Context:
> DEBUG:pynetdicom.events:  Context ID:        1 (Proposed)
> DEBUG:pynetdicom.events:    Abstract Syntax: =Patient Root Query/Retrieve Information Model - MOVE
> DEBUG:pynetdicom.events:    Proposed SCP/SCU Role: Default
> DEBUG:pynetdicom.events:    Proposed Transfer Syntaxes:
> DEBUG:pynetdicom.events:      =Implicit VR Little Endian
> DEBUG:pynetdicom.events:      =Explicit VR Little Endian
> DEBUG:pynetdicom.events:      =Explicit VR Big Endian
> DEBUG:pynetdicom.events:Requested Extended Negotiation: None
> DEBUG:pynetdicom.events:Requested Common Extended Negotiation: None
> DEBUG:pynetdicom.events:Requested Asynchronous Operations Window Negotiation: None
> DEBUG:pynetdicom.events:Requested User Identity Negotiation: None
> DEBUG:pynetdicom.events:========================== END A-ASSOCIATE-RQ PDU ==========================
> DEBUG:pynetdicom.events:Accept Parameters:
> DEBUG:pynetdicom.events:========================= BEGIN A-ASSOCIATE-AC PDU =========================
> DEBUG:pynetdicom.events:Their Implementation Class UID:    1.2.392.200193
> DEBUG:pynetdicom.events:Their Implementation Version Name: ONIS_Server 2.5
> DEBUG:pynetdicom.events:Application Context Name:    1.2.840.10008.3.1.1.1
> DEBUG:pynetdicom.events:Calling Application Name:    EGE-AI
> DEBUG:pynetdicom.events:Called Application Name:     ONIS25
> DEBUG:pynetdicom.events:Their Max PDU Receive Size:  16382
> DEBUG:pynetdicom.events:Presentation Contexts:
> DEBUG:pynetdicom.events:  Context ID:        1 (Rejected - Abstract Syntax Not Supported)
> DEBUG:pynetdicom.events:    Abstract Syntax: =Patient Root Query/Retrieve Information Model - MOVE
> DEBUG:pynetdicom.events:Accepted Extended Negotiation: None
> DEBUG:pynetdicom.events:Accepted Asynchronous Operations Window Negotiation: None
> DEBUG:pynetdicom.events:User Identity Negotiation Response: None
> DEBUG:pynetdicom.events:========================== END A-ASSOCIATE-AC PDU ==========================
> ERROR:pynetdicom.dul:Exception in DUL.run(), aborting association
> ERROR:pynetdicom.dul:list index out of range
> Traceback (most recent call last):
>   File "D:\Ege\DicomServer\venv\lib\site-packages\pynetdicom\dul.py", line 400, in run
>     elif self._is_transport_event():
>   File "D:\Ege\DicomServer\venv\lib\site-packages\pynetdicom\dul.py", line 188, in _is_transport_event
>     self._read_pdu_data()
>   File "D:\Ege\DicomServer\venv\lib\site-packages\pynetdicom\dul.py", line 324, in _read_pdu_data
>     self.primitive = self.pdu.to_primitive()
>   File "D:\Ege\DicomServer\venv\lib\site-packages\pynetdicom\pdu.py", line 892, in to_primitive
>     item.to_primitive()
>   File "D:\Ege\DicomServer\venv\lib\site-packages\pynetdicom\pdu_items.py", line 860, in to_primitive
>     self.transfer_syntax_sub_item[0].transfer_syntax_name
> IndexError: list index out of range
> ERROR:root:Association rejected, aborted or never connected

Code:
```
import logging

from pydicom.dataset import Dataset
from pynetdicom import AE, StoragePresentationContexts, evt
from pynetdicom.sop_class import (
    CTImageStorage, DigitalMammographyXRayImagePresentationStorage,
    PatientRootQueryRetrieveInformationModelMove)

from config import AE_TITLE, PEER_AE_TITLE, PEER_IP, PEER_PORT, PORT
from EventHandlers import handle_store

logging.basicConfig(level=logging.DEBUG)


def moveDataset(aeTitle, port, peerAeTitle, peerIP, peerPort, ds):

    ae = _initAE(aeTitle)
    # Start our Storage SCP in non-blocking mode, listening on port 11120
    handlers = [(evt.EVT_C_STORE, handle_store)]
    scp = ae.start_server(('', port), block=False, evt_handlers=handlers)
    logging.info("Server started.")

    try:
        # Associate with peer AE
        assoc = ae.associate(peerIP, peerPort, ae_title=peerAeTitle)
        if assoc.is_established:
            logging.info("Association established")
            _moveDatasetOverAssociation(assoc, ds)
            # Release the association
            assoc.release()
        else:
            logging.error('Association rejected, aborted or never connected')
    finally:
        # Stop Storage SCP
        scp.shutdown()

def _initAE(aeTitle):
    # Initialise the Application Entity
    ae = AE(ae_title=aeTitle)
    ae.add_requested_context(PatientRootQueryRetrieveInformationModelMove)
    # Add the Storage SCP's supported presentation contexts
    ae.add_supported_context(CTImageStorage)
    ae.add_supported_context(DigitalMammographyXRayImagePresentationStorage)
    return ae

def _moveDatasetOverAssociation(assoc, dataset):

    responses = assoc.send_c_move(dataset, assoc.ae.ae_title, query_model='P')

    for (status, identifier) in responses:
        if status:
            logging.debug('C-MOVE query status: 0x{0:04x}'.format(status.Status))

            # If the status is 'Pending' then `identifier` is the C-MOVE response
            if status.Status in (0xFF00, 0xFF01):
                logging.debug(identifier)
        else:
            logging.error('Connection timed out, was aborted or received invalid response')

if __name__ == "__main__":
    # Create out identifier (query) dataset
    ds = Dataset()
    ds.AccessionNumber = 'EU0000688977' # Balkan, Esma
    
    moveDataset(AE_TITLE, PORT, PEER_AE_TITLE, PEER_IP, PEER_PORT,ds)
```

config.py
```
AE_TITLE = b'EGE-AI'
PORT = 106

PEER_AE_TITLE = b'ONIS25'
PEER_IP = "127.0.0.1"
PEER_PORT = 104
```

Comments:

Patch:
diff --git a/docs/_static/css/pynetdicom.css b/docs/_static/css/pynetdicom.css
index 14216e0d3..39132a277 100644
--- a/docs/_static/css/pynetdicom.css
+++ b/docs/_static/css/pynetdicom.css
@@ -83,3 +83,7 @@ code.xref {
     color: #2980B9 !important;
     font-size: 80%;
 }
+
+p.first {
+    margin-bottom: 0px !important;
+}
diff --git a/docs/changelog/v1.0.0.rst b/docs/changelog/v1.0.0.rst
index a19f88431..a50d617fb 100644
--- a/docs/changelog/v1.0.0.rst
+++ b/docs/changelog/v1.0.0.rst
@@ -33,7 +33,6 @@ Changes
   - Added ``AE.add_requested_context()``, ``AE.requested_contexts``,
     ``AE.remove_requested_context()`` for adding and removing the presentation
     contexts requested as an SCU.
-
 * Removed ``VerificationSOPClass``, ``StorageSOPClassList`` and
   ``QueryRetrieveSOPClassList``.
 * Added ``VerificationPresentationContexts``, ``StoragePresentationContexts``,
diff --git a/docs/changelog/v1.4.0.rst b/docs/changelog/v1.4.0.rst
index 750f64bcb..76b6eb864 100644
--- a/docs/changelog/v1.4.0.rst
+++ b/docs/changelog/v1.4.0.rst
@@ -19,8 +19,6 @@ Fixes
 Enhancements
 ............
 
-* Added support for Protocol Approval Query Retrieve service class
-  (:issue:`327`)
 * Added ``Event.move_destination`` and ``Event.event`` properties
   (:issue:`353`)
 * Added ``meta_uid`` keyword parameters to the ``Association.send_n_*()``
@@ -28,6 +26,20 @@ Enhancements
   Print Management service class)
 * Added *Basic Grayscale Print Management Meta SOP Class* and *Basic Color
   Print Management Meta SOP Class* to ``sop_class``.
+* Added full support for the following service classes:
+
+  * Protocol Approval Query Retrieve (:issue:`327`)
+  * Application Event Logging
+  * Instance Availability Notification
+  * Media Creation Management
+* Added partial support for the following service classes:
+
+  * Print Management
+  * Unified Procedure Step
+  * RT Machine Verification
+  * Storage Commitment
+* Added handling of non-conformant Presentation Context AC (reject) PDUs which
+  contain no Transfer Syntax Sub-item (:issue:`361`)
 
 
 Changes
diff --git a/docs/conf.py b/docs/conf.py
index 734f1b465..f30abcd56 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -65,7 +65,7 @@ extensions = [
     'sphinx.ext.todo',
     'sphinx.ext.imgmath',
     'sphinx.ext.ifconfig',
-    'sphinx.ext.viewcode',
+    #'sphinx.ext.viewcode',
     #'sphinx_gallery.gen_gallery',
     'sphinx.ext.autosummary',
     'sphinx.ext.napoleon',
@@ -228,7 +228,7 @@ html_static_path = ['_static']
 # html_split_index = False
 
 # If true, links to the reST sources are added to the pages.
-# html_show_sourcelink = True
+html_show_sourcelink = False
 
 # If true, an OpenSearch description file will be output, and all pages will
 # contain a <link> tag referring to it.  The value of this option must be the
diff --git a/pynetdicom/pdu_items.py b/pynetdicom/pdu_items.py
index 698e24b6c..05ba527ec 100644
--- a/pynetdicom/pdu_items.py
+++ b/pynetdicom/pdu_items.py
@@ -856,11 +856,8 @@ class PresentationContextItemAC(PDUItem):
         primitive = PresentationContext()
         primitive.context_id = self.presentation_context_id
         primitive.result = self.result_reason
-        # May be `None` if context is rejected
-        if self.transfer_syntax_sub_item[0].transfer_syntax_name:
-            primitive.add_transfer_syntax(
-                self.transfer_syntax_sub_item[0].transfer_syntax_name
-            )
+        if self.transfer_syntax:
+            primitive.add_transfer_syntax(self.transfer_syntax)
 
         return primitive
 
@@ -976,6 +973,9 @@ class PresentationContextItemAC(PDUItem):
         Returns
         -------
         pydicom.uid.UID or None
+            If no Transfer Syntax Sub-item or an empty Transfer Syntax Sub-item
+            has been sent by the Acceptor then returns None, otherwise returns
+            the Transfer Syntax Sub-item's transfer syntax UID.
         """
         if self.transfer_syntax_sub_item:
             return self.transfer_syntax_sub_item[0].transfer_syntax_name
diff --git a/pynetdicom/presentation.py b/pynetdicom/presentation.py
index 8b31ca3fe..575ef121c 100644
--- a/pynetdicom/presentation.py
+++ b/pynetdicom/presentation.py
@@ -283,7 +283,9 @@ class PresentationContext(object):
         elif isinstance(syntax, bytes):
             syntax = UID(syntax.decode('ascii'))
         else:
-            LOGGER.error("Attempted to add an invalid transfer syntax")
+            if syntax is not None:
+                LOGGER.error("Attempted to add an invalid transfer syntax")
+
             return
 
         if syntax is not None and not validate_uid(syntax):


Test patch:
diff --git a/pynetdicom/tests/encoded_pdu_items.py b/pynetdicom/tests/encoded_pdu_items.py
index 0d9148291..d509c4070 100644
--- a/pynetdicom/tests/encoded_pdu_items.py
+++ b/pynetdicom/tests/encoded_pdu_items.py
@@ -282,7 +282,6 @@ a_associate_ac_user = b'\x02\x00\x00\x00\x00\xb8\x00\x01\x00\x00' \
                       b'\x59\x00\x00\x0a\x00\x08\x41\x63\x63\x65' \
                       b'\x70\x74\x65\x64'
 
-
 # Issue 342
 # Called AET: ANY-SCP
 # Calling AET: PYNETDICOM
@@ -321,6 +320,39 @@ a_associate_ac_zero_ts = (
     b'\x65\x43\x4f\x4d\x33\x5f\x33\x39\x30\x49\x42\x32'
 )
 
+# Issue 361
+# Called AET: ANY-SCP
+# Calling AET: PYNETDICOM
+# Application Context Name: 1.2.840.10008.3.1.1.1
+# Presentation Context Items:
+#   Presentation Context ID: 1
+#       Abstract Syntax: Verification SOP Class
+#       SCP/SCU Role: Default
+#       Result: Reject
+#       Transfer Syntax: (no Transfer Syntax Sub-Item)
+# User Information
+#   Max Length Received: 16382
+#   Implementation Class UID: 1.2.826.0.1.3680043.9.3811.1.4.0
+#   Implementation Version Name: PYNETDICOM_140
+# Extended Negotiation
+#   SOP Extended: None
+#   Async Ops: None
+#   User ID: None
+a_associate_ac_no_ts = (
+    b'\x02\x00\x00\x00\x00\xa7\x00\x01\x00\x00\x41\x4e\x59\x2d\x53\x43'
+    b'\x50\x20\x20\x20\x20\x20\x20\x20\x20\x20\x50\x59\x4e\x45\x54\x44'
+    b'\x49\x43\x4f\x4d\x20\x20\x20\x20\x20\x20\x00\x00\x00\x00\x00\x00'
+    b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
+    b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x15\x31\x2e'
+    b'\x32\x2e\x38\x34\x30\x2e\x31\x30\x30\x30\x38\x2e\x33\x2e\x31\x2e'
+    b'\x31\x2e\x31\x21\x00\x00\x04\x01\x00\x03\x00'
+    b'\x50\x00\x00\x3e\x51\x00\x00\x04\x00\x00\x3f\xfe\x52\x00\x00\x20'
+    b'\x31\x2e\x32\x2e\x38\x32\x36\x2e\x30\x2e\x31\x2e\x33\x36\x38\x30'
+    b'\x30\x34\x33\x2e\x39\x2e\x33\x38\x31\x31\x2e\x31\x2e\x34\x2e\x30'
+    b'\x55\x00\x00\x0e\x50\x59\x4e\x45\x54\x44\x49\x43\x4f\x4d\x5f\x31'
+    b'\x34\x30'
+)
+
 
 ############################# A-ASSOCIATE-RJ PDU ###############################
 # Result: Rejected (Permanent)
diff --git a/pynetdicom/tests/test_pdu.py b/pynetdicom/tests/test_pdu.py
index d35a31669..1154923ff 100644
--- a/pynetdicom/tests/test_pdu.py
+++ b/pynetdicom/tests/test_pdu.py
@@ -37,7 +37,7 @@ from pynetdicom.pdu_primitives import (
 from .encoded_pdu_items import (
     a_associate_rq, a_associate_ac, a_associate_rj, a_release_rq, a_release_rq,
     a_release_rp, a_abort, a_p_abort, p_data_tf,
-    a_associate_rq_user_id_ext_neg
+    a_associate_rq_user_id_ext_neg, a_associate_ac_no_ts
 )
 from pynetdicom.sop_class import VerificationSOPClass
 from pynetdicom.utils import pretty_bytes
@@ -660,6 +660,24 @@ class TestASSOC_AC(object):
 
         assert new == orig
 
+    def test_no_transfer_syntax(self):
+        """Regression test for #361 - ASSOC-AC has no transfer syntax"""
+        pdu = A_ASSOCIATE_AC()
+        pdu.decode(a_associate_ac_no_ts)
+
+        assert pdu.pdu_type == 0x02
+        assert pdu.pdu_length == 167
+        assert pdu.protocol_version == 0x0001
+        assert isinstance(pdu.pdu_type, int)
+        assert isinstance(pdu.pdu_length, int)
+        assert isinstance(pdu.protocol_version, int)
+
+        item = pdu.variable_items[1]
+        cx = item.to_primitive()
+        assert cx.transfer_syntax == []
+        assert cx.result == 3
+        assert cx.context_id == 1
+
 
 class TestASSOC_AC_ApplicationContext(object):
     def test_decode(self):
diff --git a/pynetdicom/tests/test_pdu_items.py b/pynetdicom/tests/test_pdu_items.py
index 48cdb77ef..a54c95283 100644
--- a/pynetdicom/tests/test_pdu_items.py
+++ b/pynetdicom/tests/test_pdu_items.py
@@ -627,6 +627,12 @@ class TestPresentationContextAC(object):
         assert len(item) == 12
         assert item.transfer_syntax is None
 
+        # Confirm we can still convert the PDU into a PresentationContext
+        primitive = item.to_primitive()
+        assert primitive.context_id == 1
+        assert primitive.transfer_syntax == []
+        assert primitive.result == 1
+
         assert "Item length: 8 bytes" in item.__str__()
 
         item = item.transfer_syntax_sub_item[0]