From ef3b1ec7b5ea192fd851b463386dd32e1f938bc2 Mon Sep 17 00:00:00 2001 From: David Wikler Date: Sat, 14 Dec 2024 17:33:56 +0100 Subject: [PATCH 1/4] Add C-ECHO SCP class and CLI app --- tdwii_plus_examples/basescp.py | 22 ++++--- tdwii_plus_examples/cechohandler.py | 22 +++++++ tdwii_plus_examples/cechoscp.py | 92 +++++++++++++++++++++++++++++ tdwii_plus_examples/cli/echoscp.py | 57 ++++++++++++++++++ 4 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 tdwii_plus_examples/cechohandler.py create mode 100644 tdwii_plus_examples/cechoscp.py create mode 100755 tdwii_plus_examples/cli/echoscp.py diff --git a/tdwii_plus_examples/basescp.py b/tdwii_plus_examples/basescp.py index e634834..a2ee739 100644 --- a/tdwii_plus_examples/basescp.py +++ b/tdwii_plus_examples/basescp.py @@ -1,4 +1,5 @@ from argparse import Namespace +import logging from pynetdicom import AE, evt from pynetdicom.apps.common import setup_logging @@ -23,7 +24,7 @@ class BaseSCP: 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) + logger(logging.Logger): A logger instance Methods: _add_contexts(self) @@ -66,8 +67,8 @@ def __init__(self, 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") + elif not isinstance(logger, logging.Logger): + raise TypeError("logger must be an instance of logging.Logger") else: self.logger = logger @@ -103,7 +104,7 @@ def _add_contexts(self): def _add_handlers(self): """ - Adds handlers for processing DICOM events from incoming associations. + Adds handlers for processing TCP events from incoming associations. This method is intended to be overridden in derived classes. """ @@ -122,10 +123,15 @@ def run(self): 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) + try: + self.threaded_server = self.ae.start_server( + (self.bind_address, self.port), + evt_handlers=self.handlers, + block=False) + if self.threaded_server is not None: + self.logger.info("SCP server started successfully") + except Exception as e: + self.logger.error("SCP server failed to start: %s", e) def stop(self): """ diff --git a/tdwii_plus_examples/cechohandler.py b/tdwii_plus_examples/cechohandler.py new file mode 100644 index 0000000..8c2505c --- /dev/null +++ b/tdwii_plus_examples/cechohandler.py @@ -0,0 +1,22 @@ +def handle_cecho(event, logger): + """Handler for evt.EVT_C_ECHO. + Parameters + ---------- + event : events.Event + The corresponding event. + logger : logging.Logger + The application's logger. + + Returns + ------- + int + The status of the C-ECHO operation, always ``0x0000`` (Success). + """ + requestor = event.assoc.requestor + timestamp = event.timestamp.strftime("%Y-%m-%d %H:%M:%S") + aet, addr, port = requestor.ae_title, requestor.address, requestor.port + logger.info( + f"Received C-ECHO request from {aet}@{addr}:{port} at {timestamp}" + ) + + return 0x0000 diff --git a/tdwii_plus_examples/cechoscp.py b/tdwii_plus_examples/cechoscp.py new file mode 100644 index 0000000..641ba4f --- /dev/null +++ b/tdwii_plus_examples/cechoscp.py @@ -0,0 +1,92 @@ +from pynetdicom import evt +from pynetdicom.sop_class import Verification + +from tdwii_plus_examples.basescp import BaseSCP +from tdwii_plus_examples.cechohandler import handle_cecho + + +class CEchoSCP(BaseSCP): + """ + A subclass of the BaseSCP class that implements the DICOM Verification + Service Class Provider (SCP). + + Usage: + Create an instance of CEchoSCP and call the run method inherited + from the BaseSCP parent class to start listening for incoming + DICOM association requests. + The CEchoSCP class is generally not used directly. Instead, 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 (logging.Logger): A logger instance + + Methods: + _add_contexts(self) + Adds presentation contexts to the SCP instance. + _add_handlers(self) + Adds handlers for DICOM communication events to the SCP instance. + """ + def __init__(self, + ae_title: str = "ECHO_SCP", + bind_address: str = "", + port: int = 11112, + logger=None): + + """ + Initializes a new instance of the CEchoSCP class. + This method creates an AE without presentation contexts. + + Parameters + ---------- + ae_title : str + The title of the Application Entity (AE) + Optional, default: "ECHO_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. + """ + super().__init__( + ae_title=ae_title, + bind_address=bind_address, + port=port, + logger=logger) + + def _add_contexts(self): + """ + Adds the DICOM Verification SOP Class presentation context to the AE. + Only the default Implicit VR Little Endian transfer syntax is included. + + This method overrides the base class method to add support for the + Verification SOP Class which is required for any DICOM SCP. + + This method is intended to be overridden in derived classes. + """ + super()._add_contexts() + self.ae.add_supported_context(Verification, "1.2.840.10008.1.2") + + def _add_handlers(self): + """ + Adds a handler for for processing incoming C-ECHO requests. + + This method overrides the base class method to add a handler + for the Verification SOP Class, allowing the AE to respond + to C-ECHO requests. + + This method is intended to be overridden in derived classes. + """ + super()._add_handlers() + self.handlers.append((evt.EVT_C_ECHO, handle_cecho, + [self.logger])) diff --git a/tdwii_plus_examples/cli/echoscp.py b/tdwii_plus_examples/cli/echoscp.py new file mode 100755 index 0000000..15976c4 --- /dev/null +++ b/tdwii_plus_examples/cli/echoscp.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +import argparse +import logging + +from tdwii_plus_examples.cechoscp import CEchoSCP + + +def main(): + parser = argparse.ArgumentParser( + description="Run a DICOM Verification SCP." + ) + parser.add_argument( + '-a', '--ae_title', type=str, default='ECHO_SCP', + help='Application Entity Title' + ) + parser.add_argument( + '-b', '--bind_address', type=str, default='', + help='Specific IP address or hostname, omit to bind to all interfaces' + ) + parser.add_argument( + '-p', '--port', type=int, default=11112, + help='Port number' + ) + parser.add_argument( + '-v', '--verbose', action='store_true', + help='Set log level to INFO' + ) + parser.add_argument( + '-d', '--debug', action='store_true', + help='Set log level to DEBUG' + ) + + args = parser.parse_args() + + log_level = logging.WARNING + if args.verbose: + log_level = logging.INFO + elif args.debug: + log_level = logging.DEBUG + + logging.basicConfig(level=log_level) + logger = logging.getLogger('echoscp') + + logger.info("Starting up the DICOM Verification SCP...") + cechoscp = CEchoSCP(ae_title=args.ae_title, bind_address=args.bind_address, + port=args.port, logger=logger) + cechoscp.run() + # Keep the main application running + try: + while True: + pass # You can replace this with your main application logic + except KeyboardInterrupt: + logger.info("Shutting down the DICOM Verification SCP...") + + +if __name__ == "__main__": + main() From 51db2d1b00602d847ffd5859acf581c4d4b3ada3 Mon Sep 17 00:00:00 2001 From: David Wikler Date: Sat, 14 Dec 2024 18:10:50 +0100 Subject: [PATCH 2/4] Replace obsolete EchoSCP class --- .../TDWII_PPVS_subscriber/echoscp.py | 33 ------------------- .../TDWII_PPVS_subscriber/nevent_receiver.py | 10 +++--- .../TDWII_PPVS_subscriber/storescp.py | 10 +++--- tdwii_plus_examples/tests/test_basescp.py | 8 ++--- 4 files changed, 14 insertions(+), 47 deletions(-) delete mode 100644 tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py deleted file mode 100644 index 59d6758..0000000 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/echoscp.py +++ /dev/null @@ -1,33 +0,0 @@ -from time import sleep - -from pynetdicom import ALL_TRANSFER_SYNTAXES, evt -from pynetdicom.sop_class import Verification - -from tdwii_plus_examples.basescp import BaseSCP -from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import ( - handle_echo, -) - - -class EchoSCP: - def __init__(self, ae_title: str = "ECHO_SCP", port: int = 11112, logger=None, bind_address: str = ""): - BaseSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) - - def _add_contexts(self): - BaseSCP._add_contexts(self) - self.ae.add_supported_context(Verification, ALL_TRANSFER_SYNTAXES) - - def _add_handlers(self): - BaseSCP._add_handlers(self) - self.handlers.append((evt.EVT_C_ECHO, handle_echo, [None, self.logger])) - - def run(self): - # Listen for incoming association requests - BaseSCP.run(self) - - -if __name__ == "__main__": - myecho_scp = EchoSCP() - myecho_scp.run() - while True: - sleep(100) # sleep forever diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py index dfc0ff2..9e651c5 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/nevent_receiver.py @@ -3,7 +3,7 @@ from pynetdicom import ALL_TRANSFER_SYNTAXES, UnifiedProcedurePresentationContexts, evt from tdwii_plus_examples.basescp import BaseSCP -from tdwii_plus_examples.TDWII_PPVS_subscriber.echoscp import EchoSCP +from tdwii_plus_examples.cechoscp import CEchoSCP from tdwii_plus_examples.TDWII_PPVS_subscriber.nevent_receiver_handlers import ( handle_nevent, ) @@ -58,7 +58,7 @@ def nevent_cb(**kwargs): logger.warning(f"Unknown Event Type ID: {event_type_id}") -class NEventReceiver(EchoSCP): +class NEventReceiver(CEchoSCP): def __init__( self, nevent_callback=None, ae_title: str = "NEVENT_RECEIVER", port: int = 11115, logger=None, bind_address: str = "" ): @@ -66,15 +66,15 @@ def __init__( self.nevent_callback = nevent_cb # fallback to something that just logs incoming events else: self.nevent_callback = nevent_callback - EchoSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) + CEchoSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) def _add_contexts(self): - EchoSCP._add_contexts(self) + CEchoSCP._add_contexts(self) for cx in UnifiedProcedurePresentationContexts: self.ae.add_supported_context(cx.abstract_syntax, ALL_TRANSFER_SYNTAXES, scp_role=True, scu_role=False) def _add_handlers(self): - EchoSCP._add_handlers(self) + CEchoSCP._add_handlers(self) self.handlers.append((evt.EVT_N_EVENT_REPORT, handle_nevent, [self.nevent_callback, None, self.logger])) def run(self): diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py index 91cdac7..49e3b74 100644 --- a/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py +++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/storescp.py @@ -6,10 +6,10 @@ 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 +from tdwii_plus_examples.cechoscp import CEchoSCP -class StoreSCP(EchoSCP): +class StoreSCP(CEchoSCP): def __init__( self, ae_title: str = "STORE_SCP", @@ -28,15 +28,15 @@ def __init__( else: self.handle_cstore = custom_handler self.store_directory = store_directory - EchoSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) + CEchoSCP.__init__(self, ae_title=ae_title, port=port, logger=logger, bind_address=bind_address) def _add_contexts(self): - EchoSCP._add_contexts(self) + CEchoSCP._add_contexts(self) for context in self.storage_presentation_contexts: self.ae.add_supported_context(context.abstract_syntax, self.transfer_syntaxes) def _add_handlers(self): - EchoSCP._add_handlers(self) + CEchoSCP._add_handlers(self) args = Namespace(ignore=False, output_directory=self.store_directory) self.handlers.append((evt.EVT_C_STORE, self.handle_cstore, [args, self.logger])) diff --git a/tdwii_plus_examples/tests/test_basescp.py b/tdwii_plus_examples/tests/test_basescp.py index 4cb471e..1c9cafa 100644 --- a/tdwii_plus_examples/tests/test_basescp.py +++ b/tdwii_plus_examples/tests/test_basescp.py @@ -7,7 +7,7 @@ from pynetdicom.sop_class import Verification -class EchoSCP(BaseSCP): +class CEchoSCP(BaseSCP): def __init__(self, bind_address, logger=None): super().__init__(bind_address=bind_address, logger=logger) @@ -36,7 +36,7 @@ def setUp(self): # 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) + self.scp = CEchoSCP(bind_address="localhost", logger=self.scp_logger) def test_run_and_check_log(self): # Run the SCP @@ -56,8 +56,8 @@ def test_run_and_check_log(self): # 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], + f"{log_messages[-2]}") + self.assertRegex(log_messages[-2], r"Succesful connection from " + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})") # Stop the SCP From 441eb50f646bec0525eabf132beec42eb882efac Mon Sep 17 00:00:00 2001 From: David Wikler Date: Sat, 14 Dec 2024 19:15:40 +0100 Subject: [PATCH 3/4] Add CEchoSCP class test --- tdwii_plus_examples/tests/test_basescp.py | 39 +++++++++---- tdwii_plus_examples/tests/test_cechoscp.py | 65 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 tdwii_plus_examples/tests/test_cechoscp.py diff --git a/tdwii_plus_examples/tests/test_basescp.py b/tdwii_plus_examples/tests/test_basescp.py index 1c9cafa..6cde278 100644 --- a/tdwii_plus_examples/tests/test_basescp.py +++ b/tdwii_plus_examples/tests/test_basescp.py @@ -3,6 +3,7 @@ from logging.handlers import MemoryHandler import subprocess import time +import re from tdwii_plus_examples.basescp import BaseSCP from pynetdicom.sop_class import Verification @@ -55,21 +56,35 @@ def test_run_and_check_log(self): 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[-2]}") - self.assertRegex(log_messages[-2], - r"Succesful connection from " + - r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})") + self.test_logger.info("Searching for EVT_CONN_OPEN event log message") + pattern = r"^Succesful connection from " + \ + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})" + matching_message = None + for message in log_messages: + if re.search(pattern, message): + matching_message = message + break + self.test_logger.info( + f"EVT_CONN_OPEN event log message found: {matching_message}") + self.assertIsNotNone(matching_message, + "EVT_CONN_OPEN event log message not found") # 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})") + # Check the EVT_CONN_CLOSE event log message + self.test_logger.info("Searching for EVT_CONN_CLOSE event log message") + pattern = r"^Closed connection with " + \ + r"([\w]+)@" + \ + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})" + matching_message = None + for message in log_messages: + if re.search(pattern, message): + matching_message = message + break + self.test_logger.info( + f"EVT_CONN_CLOSE event log message found: {matching_message}") + self.assertIsNotNone(matching_message, + "EVT_CONN_CLOSE event log message not found") if __name__ == "__main__": diff --git a/tdwii_plus_examples/tests/test_cechoscp.py b/tdwii_plus_examples/tests/test_cechoscp.py new file mode 100644 index 0000000..a63d15d --- /dev/null +++ b/tdwii_plus_examples/tests/test_cechoscp.py @@ -0,0 +1,65 @@ +import unittest +import logging +from logging.handlers import MemoryHandler +import subprocess +import time +import re +from tdwii_plus_examples.cechoscp import CEchoSCP + + +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('cechoscp') + 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_cechoscp') + self.test_logger.setLevel(logging.DEBUG) + self.stream_handler = logging.StreamHandler() + self.test_logger.addHandler(self.stream_handler) + + # Create a CEchoSCP instance + self.scp = CEchoSCP(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', 'ECHO_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] + + # Search for the EVT_C_ECHO event log message + self.test_logger.info("Searching for EVT_C_ECHO event log message") + pattern = r"^Received C-ECHO request from ECHOSCU@" + \ + r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):([\w]{3,5})" + matching_message = None + for message in log_messages: + if re.search(pattern, message): + matching_message = message + break + self.test_logger.info( + f"EVT_C_ECHO event log message found: {matching_message}") + self.assertIsNotNone(matching_message, + "EVT_C_ECHO event log message not found") + + # Stop the SCP + self.scp.stop() + + +if __name__ == "__main__": + unittest.main() From 5e64f096a0f5b8315674446b1f12c3296b3cd154 Mon Sep 17 00:00:00 2001 From: sjswerdloff Date: Sun, 15 Dec 2024 14:00:05 +0100 Subject: [PATCH 4/4] Update test_cechoscp.py Change test class to TestCEchoSCP --- tdwii_plus_examples/tests/test_cechoscp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdwii_plus_examples/tests/test_cechoscp.py b/tdwii_plus_examples/tests/test_cechoscp.py index a63d15d..15957aa 100644 --- a/tdwii_plus_examples/tests/test_cechoscp.py +++ b/tdwii_plus_examples/tests/test_cechoscp.py @@ -7,7 +7,7 @@ from tdwii_plus_examples.cechoscp import CEchoSCP -class TestBaseSCP(unittest.TestCase): +class TestCEchoSCP(unittest.TestCase): def setUp(self): # Set up the logger for the BaseSCP to INFO level