diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py deleted file mode 100644 index 1e83626..0000000 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/basescp.py +++ /dev/null @@ -1,33 +0,0 @@ -from argparse import Namespace - -from pynetdicom import AE -from pynetdicom.apps.common import setup_logging - - -class BaseSCP: - def __init__(self, ae_title: str = "BASE_SCP", port: int = 11112, logger=None, bind_address: str = ""): - self.ae_title = ae_title - self.port = port - if logger is None: - logger_args = Namespace(log_type="d", log_level="debug") - self.logger = setup_logging(logger_args, "base_scp") - else: - self.logger = logger - self.bind_address = bind_address - self.threaded_server = None - self.ae = AE(self.ae_title) - self.handlers = [] - self._add_contexts() - self._add_handlers() - - def _add_contexts(self): - # self.ae.add_supported_context(Verification, ALL_TRANSFER_SYNTAXES) - pass # base class, do nothing, pure virtual - - def _add_handlers(self): - # self.handlers.append((evt.EVT_C_ECHO, handle_echo, [None, self.logger])) - pass # base class, do nothing, pure virtual - - def run(self): - # Listen for incoming association requests - self.threaded_server = self.ae.start_server((self.bind_address, self.port), evt_handlers=self.handlers, block=False) diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py index 5c7cd05..59d6758 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py @@ -3,7 +3,7 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, evt from pynetdicom.sop_class import Verification -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP +from tdwii_plus_examples.basescp import BaseSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import ( handle_echo, ) diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py index 8b267c9..dfc0ff2 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py @@ -2,7 +2,7 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, UnifiedProcedurePresentationContexts, evt -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP +from tdwii_plus_examples.basescp import BaseSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.echoscp import EchoSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import ( handle_nevent, diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py index 1668211..c761741 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvsscp.py @@ -4,7 +4,7 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, AllStoragePresentationContexts from tdwii_plus_examples import tdwii_config -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP +from tdwii_plus_examples.basescp import BaseSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver import NEventReceiver from tdwii_plus_examples.TDWII_PPVS_subscriber.storescp import StoreSCP diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py index 0fb3fcc..91cdac7 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py @@ -4,7 +4,7 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, AllStoragePresentationContexts, evt -from tdwii_plus_examples.TDWII_PPVS_subscriber.basescp import BaseSCP +from tdwii_plus_examples.basescp import BaseSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.cstore_handler import handle_store from tdwii_plus_examples.TDWII_PPVS_subscriber.echoscp import EchoSCP diff --git a/tdwii_plus_examples/basehandlers.py b/tdwii_plus_examples/basehandlers.py new file mode 100644 index 0000000..9112679 --- /dev/null +++ b/tdwii_plus_examples/basehandlers.py @@ -0,0 +1,44 @@ +def handle_open(event, logger): + """Handler for evt.EVT_CONN_OPEN + + Parameters + ---------- + event : events.Event + The corresponding event. + logger : logging.Logger + The application's logger. + + Returns + ------- + int + The status of possible processing of opened connection parameters, + always ``0x0000`` (Success). + """ + requestor = event.assoc.requestor + addr, port = requestor.address, requestor.port + logger.info(f"Succesful connection from {addr}:{port}") + + return 0x0000 + + +def handle_close(event, logger): + """Handler for evt.EVT_CONN_CLOSE + + Parameters + ---------- + event : events.Event + The corresponding event. + logger : logging.Logger + The application's logger. + + Returns + ------- + int + The status of possible processing of closed connection parameters, + always ``0x0000`` (Success). + """ + requestor = event.assoc.requestor + aet, addr, port = requestor.ae_title, requestor.address, requestor.port + logger.info(f"Closed connection with {aet}@{addr}:{port}") + + return 0x0000 diff --git a/tdwii_plus_examples/basescp.py b/tdwii_plus_examples/basescp.py new file mode 100644 index 0000000..e634834 --- /dev/null +++ b/tdwii_plus_examples/basescp.py @@ -0,0 +1,137 @@ +from argparse import Namespace + +from pynetdicom import AE, evt +from pynetdicom.apps.common import setup_logging + +from tdwii_plus_examples.basehandlers import handle_open, handle_close + + +class BaseSCP: + """ + The BaseSCP class is a base class for DICOM Service Class Providers (SCPs). + + This class provides basic functionality for creating and managing + a DICOM Service Class Provider (SCP) including setting up the + Application Entity (AE) and handling incoming associations. + + Usage: + To use the BaseSCP class, create a subclass and override + the [_add_contexts] and [_add_handlers] methods + to add presentation contexts and handlers for specific DICOM services. + + Attributes: + ae_title (str): The title of the Application Entity (AE) + bind_address (str): The IP address or hostname of the AE + port (int): The port number the AE will listen on + logger: A logger instance (optional) + + Methods: + _add_contexts(self) + Adds presentation contexts to the SCP instance. + _add_handlers(self) + Adds handlers for DICOM communication events to the SCP instance. + run(self) + Starts the SCP AE thread. + stop(self) + Stops the SCP AE. + """ + def __init__(self, + ae_title: str = "BASE_SCP", + bind_address: str = "", + port: int = 11112, + logger=None): + + """ + Initializes a new instance of the BaseSCP class. + This method creates an AE without presentation contexts. + + Parameters + ---------- + ae_title : str + The title of the Application Entity (AE) + Optional, default: "BASE_SCP" + + bind_address : str + A specific IP address or hostname of the AE + Optional, default: "" will bind to all interfaces. + + port: int + The port number to listen on + Optional, default: 11112 (as registered for DICOM at IANA) + + logger: logging.Logger + A logger instance + Optional, default: None, a debug logger will be used. + """ + if logger is None: + self.logger = setup_logging( + Namespace(log_type="d", log_level="debug"), "base_scp") + # elif not isinstance(self.logger, logging.Logger): + # raise TypeError("logger must be an instance of logging.Logger") + else: + self.logger = logger + + if not isinstance(bind_address, str): + raise ValueError("bind_address must be a string") + if not bind_address: + self.logger.debug("bind_address empty, binding to all interfaces") + self.bind_address = bind_address + + if not isinstance(port, int): + raise TypeError("port must be an integer") + self.port = port + + if not isinstance(ae_title, str): + raise TypeError("ae_title must be a string") + self.ae_title = ae_title + self.ae = AE(self.ae_title) + + self._add_contexts() + + self.handlers = [] + self._add_handlers() + + self.threaded_server = None + + def _add_contexts(self): + """ + Adds DICOM presentation contexts to the SCP class. + + This method is intended to be overridden in derived classes. + """ + pass # base class, do nothing, pure virtual + + def _add_handlers(self): + """ + Adds handlers for processing DICOM events from incoming associations. + + This method is intended to be overridden in derived classes. + """ + # To define actions when a TCP connection in opened or closed + self.handlers.append((evt.EVT_CONN_OPEN, handle_open, + [self.logger])) + self.handlers.append((evt.EVT_CONN_CLOSE, handle_close, + [self.logger])) + + def run(self): + """ + Start the SCP server and listen for incoming association requests + + This method starts an SCP server and begins listening for incoming + association requests. The server is run in a separate thread and will + not block the calling thread. + """ + # Listen for incoming association requests + self.threaded_server = self.ae.start_server( + (self.bind_address, self.port), + evt_handlers=self.handlers, + block=False) + + def stop(self): + """ + Stop the SCP server. + + This method shuts down the SCP server by terminating any active + association servers and thread of the AE. + """ + self.ae.shutdown() diff --git a/tdwii_plus_examples/tests/test_basescp.py b/tdwii_plus_examples/tests/test_basescp.py new file mode 100644 index 0000000..4cb471e --- /dev/null +++ b/tdwii_plus_examples/tests/test_basescp.py @@ -0,0 +1,76 @@ +import unittest +import logging +from logging.handlers import MemoryHandler +import subprocess +import time +from tdwii_plus_examples.basescp import BaseSCP +from pynetdicom.sop_class import Verification + + +class EchoSCP(BaseSCP): + def __init__(self, bind_address, logger=None): + super().__init__(bind_address=bind_address, logger=logger) + + def _add_contexts(self): + super()._add_contexts() + self.ae.add_supported_context(Verification, "1.2.840.10008.1.2") + + +class TestBaseSCP(unittest.TestCase): + + def setUp(self): + # Set up the logger for the BaseSCP to INFO level + # with a memory handler to store up to 100 log messages + self.scp_logger = logging.getLogger('basescp') + self.scp_logger.setLevel(logging.INFO) + self.memory_handler = MemoryHandler(100) + self.scp_logger.addHandler(self.memory_handler) + + # Set up the logger for this test to DEBUG level + # with a stream handler to print the log messages to the console + self.test_logger = logging.getLogger('test_basescp') + self.test_logger.setLevel(logging.DEBUG) + self.stream_handler = logging.StreamHandler() + self.test_logger.addHandler(self.stream_handler) + + # Create a subclass of BaseSCP with only 1 presentation context: + # the required Verification presentation context with the + # default DICOM transfer syntax (Implicit VR Little Endian) + self.scp = EchoSCP(bind_address="localhost", logger=self.scp_logger) + + def test_run_and_check_log(self): + # Run the SCP + self.scp.run() + + # Send an echo request using pynetdicom's echoscu.py + subprocess.check_call(['python', '-m', 'pynetdicom', 'echoscu', + 'localhost', '11112', + '-aet', 'ECHOSCU', '-aec', 'BASE_SCP']) + + # Wait for 1 second to ensure the logs are generated + time.sleep(1) + + # Get the log messages + log_messages = [record.getMessage() for record + in self.memory_handler.buffer] + + # Check the EVT_CONN_OPEN event log message + self.test_logger.info(f"Checking EVT_CONN_OPEN event log message: " + f"{log_messages[0]}") + self.assertRegex(log_messages[0], + r"Succesful connection from " + + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})") + # Stop the SCP + self.scp.stop() + + # Check the EVT_CONN_CLOSED event log message + self.test_logger.info(f"Checking EVT_CONN_CLOSE event log message: " + f"{log_messages[-1]}") + self.assertRegex(log_messages[-1], + r"Closed connection with " + + r"([\w]+)@" + + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})") + + +if __name__ == "__main__": + unittest.main()