Repository: sjswerdloff/tdwii_plus_examples
Pull request #126: Refactor nactionscu
Author: login: dwikler
Created: 2025-05-03T12:20:25Z | Merged: 2025-05-10T16:37:36Z

This merge request addresses part of issue https://github.com/sjswerdloff/tdwii_plus_examples/issues/85.
It refactors nactionscu.py from the tdwii_plus_examples folder.
The NActionSCU class is refactored as UPSPullNActionSCU in tdwii_plus_examples package folder in upspullnactionscu.py.
UPSPullNActionSCU is derived from a BaseSCU class.
The nactionscu CLI application in tdwii_plus_examples package folder is also refactored as upschangestatescu.py CLI application and moved to the tdwii_plus_examples/cli folder.
Tests are provided.
Finally, the GUI application in tdwii_plus_examples/tdd folder is refactored to use UPSPullNActionSCU class.

## Summary by Sourcery

Refactor the UPS (Unified Procedure Step) related classes and CLI application to improve modularity, separation of concerns, and enhance the implementation of the UPS Pull N-Action Service Class User (SCU).

New Features:
- Implement UPSPullNActionSCU class for managing UPS state changes
- Create upschangestatescu CLI application for UPS state management

Enhancements:
- Refactor BaseSCU to improve association and context handling
- Improve error handling and logging in DICOM service classes
- Add more robust transaction UID management for UPS operations

Tests:
- Add comprehensive unit and integration tests for UPSPullNActionSCU
- Enhance existing test coverage for BaseSCU and related classes

Chores:
- Reorganize project structure
- Improve code documentation


Diff:
diff --git a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py
index f68e112..89d9706 100755
--- a/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py
+++ b/tdwii_plus_examples/TDWII_PPVS_subscriber/ppvs_subscriber_widget.py
@@ -81,14 +81,14 @@ def __init__(self, parent=None):
                     self.ui.machine_name_line_edit.setText(machine_name)
                 logging.warning(f"Completed Parsing of {config_file_path}")
             except Exception:
-                logging.warning("Difficulty parsing config file " f"{config_file_path}")
+                logging.warning(f"Difficulty parsing config file {config_file_path}")
         except OSError as config_file_error:
-            logging.exception("Problem parsing config file " f"{config_file_path}: {config_file_error}")
+            logging.exception(f"Problem parsing config file {config_file_path}: {config_file_error}")
 
         if "ae_title" in default_dict and "import_staging_directory" in default_dict:
             self._restart_scp()
         else:
-            logging.error(f"AE Title and Staging Directory missing from " f"config file {config_file_path}")
+            logging.error(f"AE Title and Staging Directory missing from config file {config_file_path}")
 
     @Slot()
     def _import_staging_dir_clicked(self):
@@ -343,9 +343,10 @@ def _subscribe_to_ups(self, match_on_step_state=False, match_on_beam_number=Fals
         if result.status_category != "Success":
             status = False
             logging.error("Error subscribing to UPS: " + result.status_description)
-        if result.status_category == "Success" and self.watch_scu is None:
+        if result.status_category == "Success":
             status = True
-            self.watch_scu = watch_scu
+            if self.watch_scu is None:
+                self.watch_scu = watch_scu
 
         return status
 
diff --git a/tdwii_plus_examples/_ups_action_type_id.py b/tdwii_plus_examples/_ups_action_type_id.py
new file mode 100644
index 0000000..bd7717b
--- /dev/null
+++ b/tdwii_plus_examples/_ups_action_type_id.py
@@ -0,0 +1,11 @@
+# tdwii_plus_examples/action_type_id.py
+from enum import Enum, unique
+
+
+@unique
+class UPSActionTypeID(Enum):
+    CHANGE_UPS_STATE = 1
+    REQUEST_UPS_CANCEL = 2
+    SUBSCRIBE = 3
+    UNSUBSCRIBE = 4
+    SUSPEND = 5
diff --git a/tdwii_plus_examples/basescu.py b/tdwii_plus_examples/basescu.py
index f37b6db..97a4d84 100644
--- a/tdwii_plus_examples/basescu.py
+++ b/tdwii_plus_examples/basescu.py
@@ -12,16 +12,21 @@
 
 class BaseSCU:
     """
-    The BaseSCU class is a base class for DICOM Service Class Providers (SCUs).
+    The BaseSCU class is a base class for DICOM Service Class Users (SCUs).
 
     This class provides basic functionality for creating and managing
     a DICOM Service Class Provider (SCU) including setting up the
     Application Entity (AE), establishing and Association with an SCP and
     handling incoming responses from SCP.
+    It also implements the Verification SOP Class SCU.
 
     Usage:
-        To use the BaseSCU class, create a subclass and override
-        the [_add_contexts] method to add requested presentation contexts.
+        To use the BaseSCU class, create a subclass then,
+        override:
+        - [_add_contexts] method to add requested presentation contexts.
+        - [_get_status_description] method to add response status codes.
+        use:
+        - [_associate] method to implement Service primitives requests
 
     Attributes:
         ae_title (str): The title of the Application Entity (AE)
@@ -30,16 +35,18 @@ class BaseSCU:
         logger(logging.Logger): A logger instance
 
     Methods:
-        _add_requested_contexts(self)
-            Adds presentation contexts to the SCU instance.
-        _associate(self)
-            Open an Association of the SCU instance to an SCP.
-        _handle_response(self)
-            Handle the response of the request sent by the SCU.
-        all_contexts_accepted(self, assoc)
-            Check if all requested presentation contexts were accepted.
+        _add_requested_context()
+            Adds the Verification SOP Class presentation context.
+            Should be overridden by subclasses.
+        _associate()
+            Establishes association with remote AE.
+        _get_status_description()
+            Retrieves the description for a given status code.
+            Should be overridden by subclasses.
+        set_called_ae()
+            Sets the called AE parameters.
         verify()
-            Send a Verification (C-ECHO) request from the SCU
+            Sends a verification request.
     """
 
     def __init__(
@@ -51,10 +58,11 @@ def __init__(
         called_ae_title: str = None,
     ):
         """
-        Initializes a new instance of the Base SCU AE class.
-        This method sets up the AE with the provided parameters
-        and adds the requested presentation context to negotiate
-        the UPS Push SOP Class.
+        Initializes a BaseSCU instance.
+
+        This method sets up the AE and calls the method _add_requested_context
+        which is meant to be overriden by subclasses.
+        BaseSCU adds the Verification SOP Class presentation context.
 
         Parameters
         ----------
@@ -64,8 +72,8 @@ def __init__(
             The IP address of the called AE.
         called_port : int
             The port number of the called AE.
-        called_ae_title : str
-            The AE title of the called AE.
+        called_ae_title : str, optional
+            The AE title of the called AE. If None, "ANY-SCP" is used.
         calling_ae_title : str
             The AE title of the calling AE.
         """
@@ -103,11 +111,11 @@ def __init__(
 
     def _add_requested_context(self):
         """
-        Adds the DICOM UPS Push SOP Class presentation context to the AE.
+        Adds the DICOM Verification SOP Class presentation context to the AE.
         Default transfer syntaxes are included.
 
-        This method may be overridden in derived classes if another SOP
-        Class needs to be negotiated.
+        Derived classes *must* override this method to add the presentation
+        contexts for the specific SOP Classes they support.
         """
         self.ae.add_requested_context(Verification)
         self.logger.debug(f"Verification Presentation context added: \n {self.ae.requested_contexts[0]}")
@@ -115,61 +123,107 @@ def _add_requested_context(self):
     # Create a named tuple to hold the result of the Association
     AssociationResult = namedtuple("AssociationResult", ["status", "description", "accepted_sop_classes"])
 
-    def _associate(self):
+    def _associate(self, required_sop_classes=None):
         """
-        Establish an association with the remote AE and check contexts.
+        Establishes an association with the remote AE.
 
         Returns:
-            AssociationResult: A named tuple containing the status, accepted SOP classes, and description.
+            tuple: (success: bool, result: AssociationResult)
+                - success (bool): True if association status is "Success", False otherwise.
+                - result (AssociationResult): The association result namedtuple.
         """
+        result = self._attempt_association(required_sop_classes)
+        return (result.status == "Success"), result
+
+    def _attempt_association(self, required_sop_classes=None):
         if (self.called_ip is None or self.called_ip == "") or self.called_port is None:
             return self.AssociationResult(status="Error", description="Called AE parameters not set", accepted_sop_classes=[])
-        else:
-            if self.called_ae_title is None:
-                self.called_ae_title = "ANYSCP"
-                self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
 
-            self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
-            if not self.assoc.is_established:
-                return self.AssociationResult(
-                    status="Error", description="Association could not be established", accepted_sop_classes=[]
-                )
-            self.logger.debug("Association established")
+        if self.called_ae_title is None:
+            self.called_ae_title = "ANYSCP"
+            self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
+
+        self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
+        if not self.assoc.is_established:
+            return self.AssociationResult(
+                status="Error", description="Association could not be established", accepted_sop_classes=[]
+            )
+
+        self.logger.debug("Association established")
 
-            # Initialize status of future requests
-            self.status = False
+        # Initialize status of future requests
+        self.status = False
 
-            # Check if all requested contexts were accepted
-            return self._all_contexts_accepted(self.assoc)
+        # Check if all SOP Classes of requested contexts were accepted
+        result = self._check_accepted_sop_classes(self.assoc)
+        if result.status:
+            return self.AssociationResult(
+                status="Success",
+                description="All requested SOP Classes were accepted",
+                accepted_sop_classes=[UID(uid) for uid in result.accepted_sop_classes],
+            )
+        else:
+            # Check if all required contexts were accepted
+            return self._validate_sop_classes(result, required_sop_classes)
 
-    def _all_contexts_accepted(self, assoc: Association) -> AssociationResult:
+    def _check_accepted_sop_classes(self, assoc: Association) -> AssociationResult:
         """
-        Check if all requested presentation contexts were accepted.
+        Check if all requested DICOM SOP classes are accepted.
+
+        Returns an AssociationResult indicating "Success" if all SOP classes are accepted
+        and "Warning" if only some are accepted.
 
         Args:
-            assoc (Association): The established association object.
+            assoc (pynetdicom.association.Association): The established association.
 
         Returns:
-            AssociationResult: A named tuple containing the status, accepted SOP classes, and description.
+            An AssociationResult namedtuple.
         """
         requested_abstract_syntaxes = {ctx.abstract_syntax for ctx in self.ae.requested_contexts}
         accepted_abstract_syntaxes = {ctx.abstract_syntax for ctx in assoc.accepted_contexts}
 
-        refused_abstract_syntaxes = requested_abstract_syntaxes - accepted_abstract_syntaxes
+        rejected_abstract_syntaxes = requested_abstract_syntaxes - accepted_abstract_syntaxes
 
-        if refused_abstract_syntaxes:
+        if rejected_abstract_syntaxes:
             accepted_sop_classes = [UID(uid) for uid in accepted_abstract_syntaxes]
-            description = "The following SOP classes were not accepted: " + ", ".join(
-                str(uid) for uid in refused_abstract_syntaxes
+            self.logger.warning(
+                "The following SOP Classes were rejected: " + ", ".join(str(uid) for uid in rejected_abstract_syntaxes)
             )
-            return self.AssociationResult(status="Warning", description=description, accepted_sop_classes=accepted_sop_classes)
+            return self.AssociationResult(status=False, description="", accepted_sop_classes=accepted_sop_classes)
 
         return self.AssociationResult(
-            status="Success",
-            description="All requested presentation contexts were accepted",
+            status=True,
+            description="",
             accepted_sop_classes=[UID(uid) for uid in accepted_abstract_syntaxes],
         )
 
+    def _validate_sop_classes(self, assoc_result, required_sop_classes=None):
+        """Validates if all required SOP classes are accepted.
+
+        Args:
+            assoc_result (AssociationResult): The association result.
+            required_sop_classes (list, optional): A list of required SOP Class UIDs.
+
+        Returns:
+            An AssociationResult namedtuple.
+        """
+        accepted_sop_classes = {UID(uid) for uid in assoc_result.accepted_sop_classes}
+
+        # Use a set for efficient check
+        if required_sop_classes:
+            required_sop_classes = {UID(uid) for uid in required_sop_classes}
+        else:
+            required_sop_classes = {context.abstract_syntax for context in self.ae.requested_contexts}
+
+        if not required_sop_classes.issubset(accepted_sop_classes):
+            missing_sop_classes = list(required_sop_classes - accepted_sop_classes)
+            description = f"The following required SOP classes were rejected: {', '.join(map(str, missing_sop_classes))}"
+            self.logger.error(description)
+            self.assoc.release()
+            return self.AssociationResult("Error", description, list(accepted_sop_classes))
+
+        return self.AssociationResult("Success", "All required SOP classes were accepted", list(accepted_sop_classes))
+
     # Create a named tuple to hold the response status and dataset
     PrimitiveResult = namedtuple("PrimitiveResult", ["status_category", "status_code", "status_description", "dataset"])
 
@@ -220,9 +274,37 @@ def _handle_response(self, rsp_status, rsp_dataset) -> PrimitiveResult:
         return self.PrimitiveResult(category, status_code, description, rsp_ds)
 
     def _get_status_description(self, status_code):
+        """
+        Retrieves the description for a given status code.
+
+        This method retrieves the description associated with a given status
+        code from the GENERAL_STATUS dictionary. If the status code is not
+        found, it returns "Unknown".  This method is intended to be overridden
+        by subclasses to provide more specific status descriptions.
+
+        Args:
+            status_code (int): The status code to look up.
+
+        Returns:
+            str: The description of the status code.
+        """
         return GENERAL_STATUS.get(status_code, ("Unknown", "Unknown status code"))[1]
 
     def set_called_ae(self, called_ip: str, called_port: int, called_ae_title: str = None):
+        """
+        Sets the called AE parameters.
+
+        This method allows setting the called AE parameters (IP, port, and
+        AE title) after the BaseSCU object has been initialized.  It's
+        useful when the called AE information is not available during
+        object creation.
+
+        Args:
+            called_ip (str): The IP address of the called AE.
+            called_port (int): The port number of the called AE.
+            called_ae_title (str, optional): The AE title of the called AE.
+                Defaults to "ANY-SCP".
+        """
         self.called_ip = called_ip
         self.called_port = called_port
         if called_ae_title is None or called_ae_title == "":
@@ -245,17 +327,17 @@ def verify(self):
         PrimitiveResult:
             A named tuple containing the status category, status code, status description, and dataset.
         """
-        assoc_result = self._associate()
+        success, details = self._associate(required_sop_classes=[Verification])
 
-        if assoc_result.status == "Error":
-            return self.PrimitiveResult("AssocFailure", 0xD000, assoc_result.description, None)
+        if details.status == "Error":
+            return self.PrimitiveResult("AssocFailure", 0xD000, details.description, None)
 
-        if assoc_result.status == "Warning":
-            accepted_sop_names = [f"[{UID(uid).name}]" for uid in assoc_result.accepted_sop_classes]
-            self.logger.warning(f"{assoc_result.description} - Accepted SOP Classes: {', '.join(accepted_sop_names)}")
+        if details.status == "Warning":
+            accepted_sop_names = [f"[{UID(uid).name}]" for uid in details.accepted_sop_classes]
+            self.logger.warning(f"{details.description} - Accepted SOP Classes: {', '.join(accepted_sop_names)}")
             if "[Verification SOP Class]" not in accepted_sop_names:
                 self.assoc.release()
-                return self.PrimitiveResult("AssocFailure", 0xD001, assoc_result.description, None)
+                return self.PrimitiveResult("AssocFailure", 0xD001, details.description, None)
 
         result = self._handle_response(self.assoc.send_c_echo(), None)
 
diff --git a/tdwii_plus_examples/cli/storescu.py b/tdwii_plus_examples/cli/storescu.py
index 98748fe..2dcb6cf 100644
--- a/tdwii_plus_examples/cli/storescu.py
+++ b/tdwii_plus_examples/cli/storescu.py
@@ -84,7 +84,7 @@ def main():
     parser.add_argument("ip", type=str, help="IP address or hotname of called AE")
     parser.add_argument("port", type=int, help="TCP port number of called AE")
     parser.add_argument(
-        "path", metavar="path", nargs="+", help="DICOM file or directory containing SOP instances to store", type=str
+        "path", metavar="path", nargs="*", help="DICOM file or directory containing SOP instances to store", type=str
     )
     parser.add_argument("-r", "--recurse", help="recursively search the given directory", action="store_true")
     parser.add_argument("-aet", "--ae_title", type=str, default="STORESCU", help="Application Entity Title")
@@ -96,6 +96,12 @@ def main():
 
     args = parser.parse_args()
 
+    if not args.echo and not args.path:
+        parser.error("Either -e/--echo or a path must be provided")
+
+    if args.echo and args.path:
+        print("Warning: Path argument is ignored when using -e/--echo option")
+
     if args.quiet:
         log_level = logging.CRITICAL
     elif args.verbose:
@@ -129,7 +135,7 @@ def main():
             print(f"Verification (C-ECHO) failed: {num_created_instances.status_description}")
 
     # UPS instances creation requested
-    elif args.path is not None:
+    elif args.path:
         valid_paths, invalid_paths = get_files(args.path, args.recurse)
 
         for path in invalid_paths:
diff --git a/tdwii_plus_examples/cli/upschangestatescu.py b/tdwii_plus_examples/cli/upschangestatescu.py
new file mode 100644
index 0000000..c727f39
--- /dev/null
+++ b/tdwii_plus_examples/cli/upschangestatescu.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python
+"""
+A DICOM UPS Pull Service Class User (SCU) application.
+
+This application implements a UPS Pull N-ACTION - Change UPS State SOP Class SCU.
+It uses the DIMSE N-ACTION Service to change the state of UPS instances on a remote SCP.
+
+Usage:
+    upschangestatescu [options] ip port ups_uid state [transaction_uid]
+
+Arguments:
+    ip              IP address of called AE
+    port            TCP port number of called AE
+    ups_uid         SOP Instance UID of the UPS instance
+    state           Requested procedure step state ("IN PROGRESS", "CANCELED", or "COMPLETED")
+    transaction_uid Transaction UID of the UPS (required if state is "CANCELED" or "COMPLETED")
+
+Options:
+    -h, --help               Show this help message and exit
+    -aet, --ae_title         Application Entity Title (default: UPSPULLSCU)
+    -aec, --called_ae_title  Called Application Entity Title (default: ANYSCP)
+    -e, --echo               Verification of DICOM connection with Called AET
+    -v, --verbose            Set log level to INFO
+    -d, --debug              Set log level to DEBUG
+    -q, --quiet              Suppress all log output
+"""
+
+import argparse
+import logging
+import sys
+
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Send a DICOM UPS Push N-CREATE request")
+    parser.add_argument("ip", type=str, help="IP address or hotname of called AE")
+    parser.add_argument("port", type=int, help="TCP port number of called AE")
+    parser.add_argument("ups_uid", help="SOP Instance UID of the UPS", type=str)
+    parser.add_argument(
+        "state",
+        nargs="?",
+        choices=["IN PROGRESS", "CANCELED", "COMPLETED"],
+        default="IN PROGRESS",
+        help='Requested procedure step state (default: "IN PROGRESS")',
+    )
+    parser.add_argument(
+        "transaction_uid",
+        nargs="?",
+        type=str,
+        default=None,
+        help="Transaction UID of the UPS (required for CANCELED or COMPLETED)",
+    )
+
+    parser.add_argument("-aet", "--ae_title", type=str, default="UPSPULLSCU", help="Application Entity Title")
+    parser.add_argument("-aec", "--called_ae_title", type=str, default="ANYSCP", help="Called Application Entity Title")
+    parser.add_argument("-e", "--echo", action="store_true", help="Verification of DICOM connection with Called AET")
+    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")
+    parser.add_argument("-q", "--quiet", action="store_true", help="Suppress all log output")
+
+    args = parser.parse_args()
+
+    # Argument consistency checks
+    if not args.echo and not args.ups_uid:
+        parser.error("Either -e/--echo or a ups_uid must be provided")
+    if args.echo and args.ups_uid:
+        print("Warning: ups_uid argument is ignored when using -e/--echo option")
+    if args.state == "IN PROGRESS" and args.transaction_uid:
+        parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+    if args.state in ("CANCELED", "COMPLETED") and not args.transaction_uid:
+        parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+
+    if args.quiet:
+        log_level = logging.CRITICAL
+    elif args.verbose:
+        log_level = logging.INFO
+    elif args.debug:
+        log_level = logging.DEBUG
+    else:
+        log_level = logging.WARNING
+
+    logging.basicConfig(level=log_level)
+    logger = logging.getLogger("upspullnactionscu")
+    logger.setLevel(log_level)
+
+    logger.info(f"Initializing UPSPullNActionSCU instance with remote SCP {args.called_ae_title}@{args.ip}:{args.port}")
+    scu = UPSPullNActionSCU(
+        logger=logger,
+        calling_ae_title=args.ae_title,
+        called_ip=args.ip,
+        called_port=args.port,
+        called_ae_title=args.called_ae_title,
+    )
+
+    # Verification requested
+    if args.echo:
+        print("Verification (C-ECHO) request")
+        result = scu.verify()
+        if result.status_category == "Success":
+            print("Verification (C-ECHO) successful")
+        else:
+            print(f"Verification (C-ECHO) failed: {result.status_description}")
+            sys.exit(1)
+
+    # UPS instances state change requested
+    elif args.ups_uid is not None:
+        ups_instance_uid = args.ups_uid
+        if args.state == "IN PROGRESS":
+            if args.transaction_uid:
+                parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+            result = scu.claim_ups(ups_instance_uid)
+        elif args.state in ("CANCELED", "COMPLETED"):
+            if not args.transaction_uid:
+                parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+            if args.state == "CANCELED":
+                result = scu.cancel_ups(ups_instance_uid, args.transaction_uid)
+            else:
+                result = scu.complete_ups(ups_instance_uid, args.transaction_uid)
+        else:
+            parser.error(f"Unknown state: {args.state}")
+
+        if result:
+            print(f"Change UPS State to {args.state} successful")
+            if args.state == "IN PROGRESS":
+                print(f"Transaction UID: {result}")
+        else:
+            print("Change UPS State failed")
+            sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tdwii_plus_examples/cstorescu.py b/tdwii_plus_examples/cstorescu.py
index 47c1d76..88592cc 100644
--- a/tdwii_plus_examples/cstorescu.py
+++ b/tdwii_plus_examples/cstorescu.py
@@ -1,5 +1,5 @@
 from pydicom import Dataset
-from pydicom.uid import UID, ExplicitVRLittleEndian
+from pydicom.uid import ExplicitVRLittleEndian
 from pynetdicom.association import Association
 from pynetdicom.presentation import PresentationContext, StoragePresentationContexts, build_context
 from pynetdicom.status import STORAGE_SERVICE_CLASS_STATUS
@@ -101,10 +101,11 @@ def store_instances(self, instances: list[Dataset]) -> int:
         Returns:
             int: The number of DICOM instances successfully stored.
         """
-        assoc_result = self._associate()
+        required_sop_classes = [instance.SOPClassUID for instance in instances]
+        success, _ = self._associate(required_sop_classes=required_sop_classes)
         success_count = 0
 
-        if not self._handle_association_status(assoc_result):
+        if not success:
             return 0
 
         for msg_id, instance in enumerate(instances, start=0):
@@ -154,36 +155,6 @@ def _validate_contexts(self, contexts):
     def is_storage_presentation_context(self, context):
         return context in storage_sop_classes
 
-    def _handle_association_status(self, assoc_result) -> bool:
-        """Handles the association status returned by the SCP.
-
-        Handles the association result and verifies that all requested
-        presentation contexts have been accepted.
-
-        Args:
-            assoc_result: The result of the association request.
-
-        Returns:
-            True if the association was successful and all requested presentation
-            contexts were accepted, False otherwise.
-        """
-        if assoc_result.status == "Error":
-            self.logger.error(f"Association failed: {assoc_result.description}")
-            return False
-        if assoc_result.status == "Warning":
-            accepted_contexts = {UID(uid): 0x00 for uid in assoc_result.accepted_sop_classes}
-            if any(accepted_contexts.get(context.abstract_syntax) != 0x00 for context in self.contexts):
-                not_accepted = [
-                    str(context.abstract_syntax)
-                    for context in self.contexts
-                    if accepted_contexts.get(context.abstract_syntax) != 0x00
-                ]
-                self.logger.warning(f"{assoc_result.description} - Not Accepted SOP Classes: {', '.join(not_accepted)}")
-                self.assoc.release()
-                self.logger.error("Association failed: Not all required presentation contexts were accepted.")
-                return False
-        return True
-
     def _send_cstore_request(self, assoc: Association, dataset: Dataset, msg_id: int = 1) -> tuple:
         """
         Send an C-STORE request.
diff --git a/tdwii_plus_examples/tdd/tdd_widget.py b/tdwii_plus_examples/tdd/tdd_widget.py
index 4bb295e..b554674 100755
--- a/tdwii_plus_examples/tdd/tdd_widget.py
+++ b/tdwii_plus_examples/tdd/tdd_widget.py
@@ -25,7 +25,6 @@
 
 from tdwii_plus_examples import cmove_inputs, tdwii_config
 from tdwii_plus_examples.cstorescu import CStoreSCU
-from tdwii_plus_examples.nactionscu import CANCELED, COMPLETED, IN_PROGRESS, NActionSCU
 from tdwii_plus_examples.nsetscu import NSetSCU
 
 # Important:
@@ -38,6 +37,7 @@
     create_ups_query,
     get_ups,
 )
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
 from tdwii_plus_examples.upswatchnactionscu import UPSWatchNActionSCU
 
 
@@ -117,15 +117,18 @@ def _start_procedure(self):
         self.clearing_flag = False
         my_ae_title = self.ui.tdd_ae_line_edit.text()
         tms_ae_title = self.ui.ups_ae_line_edit.text()
-        naction_scu = NActionSCU(calling_ae_title=my_ae_title, called_ae_title=tms_ae_title)
+        ip_addr = tdwii_config.known_ae_ipaddr[tms_ae_title]
+        port = tdwii_config.known_ae_port[tms_ae_title]
+        naction_scu = UPSPullNActionSCU(
+            calling_ae_title=my_ae_title, called_ae_title=tms_ae_title, called_ip=ip_addr, called_port=port
+        )
         ups_uid = self._get_currently_selected_ups_uid()
         if self.current_transaction_uid is not None:
             logging.warning(
                 "There is a Transaction UID in use, starting multiple procedures in parallel \
                     with the TMS is not typical behavior"
             )
-        naction_scu.send_procedure_step_state_change(IN_PROGRESS, ups_uid=ups_uid)
-        self.current_transaction_uid = naction_scu.current_transaction_uid()
+        self.current_transaction_uid = naction_scu.claim_ups(sop_instance_uid=ups_uid)
         self.ui.transaction_uid_line_edit.setText(str(self.current_transaction_uid))
         self._send_beam_and_session_percentage_update()  # need to send first beam and 0% progress
 
@@ -133,15 +136,16 @@ def _start_procedure(self):
     def _cancel_procedure(self):
         my_ae_title = self.ui.tdd_ae_line_edit.text()
         tms_ae_title = self.ui.ups_ae_line_edit.text()
-        naction_scu = NActionSCU(
-            calling_ae_title=my_ae_title, called_ae_title=tms_ae_title, transaction_uid=self.current_transaction_uid
+        ip_addr = tdwii_config.known_ae_ipaddr[tms_ae_title]
+        port = tdwii_config.known_ae_port[tms_ae_title]
+        naction_scu = UPSPullNActionSCU(
+            calling_ae_title=my_ae_title, called_ae_title=tms_ae_title, called_ip=ip_addr, called_port=port
         )
-        # naction_scu.transaction_uid = self.current_transaction_uid
         ups_uid = self._get_currently_selected_ups_uid()
         if self.current_transaction_uid is None:
             logging.warning("There is no Transaction UID in use, unable to cancel")
             return
-        naction_scu.send_procedure_step_state_change(CANCELED, ups_uid=ups_uid)
+        naction_scu.cancel_ups(sop_instance_uid=ups_uid, transaction_uid=self.current_transaction_uid)
         self.current_transaction_uid = None
         self.ui.transaction_uid_line_edit.setText("")
         self.clearing_flag = True
@@ -152,16 +156,16 @@ def _cancel_procedure(self):
     def _complete_procedure(self):
         my_ae_title = self.ui.tdd_ae_line_edit.text()
         tms_ae_title = self.ui.ups_ae_line_edit.text()
-        naction_scu = NActionSCU(
-            calling_ae_title=my_ae_title, called_ae_title=tms_ae_title, transaction_uid=self.current_transaction_uid
+        ip_addr = tdwii_config.known_ae_ipaddr[tms_ae_title]
+        port = tdwii_config.known_ae_port[tms_ae_title]
+        naction_scu = UPSPullNActionSCU(
+            calling_ae_title=my_ae_title, called_ae_title=tms_ae_title, called_ip=ip_addr, called_port=port
         )
-
-        # naction_scu.current_transaction_uid = self.current_transaction_uid
         ups_uid = self._get_currently_selected_ups_uid()
         if self.current_transaction_uid is None:
             logging.warning("There is no Transaction UID in use, unable to complete")
             return
-        naction_scu.send_procedure_step_state_change(COMPLETED, ups_uid=ups_uid)
+        naction_scu.complete_ups(sop_instance_uid=ups_uid, transaction_uid=self.current_transaction_uid)
         self.current_transaction_uid = None
         self.ui.transaction_uid_line_edit.setText("")
         self.clearing_flag = True
diff --git a/tdwii_plus_examples/tests/cli/test_upschangestatescu.py b/tdwii_plus_examples/tests/cli/test_upschangestatescu.py
new file mode 100644
index 0000000..eec5533
--- /dev/null
+++ b/tdwii_plus_examples/tests/cli/test_upschangestatescu.py
@@ -0,0 +1,73 @@
+import sys
+import unittest
+from io import StringIO
+from unittest import mock
+
+import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu
+
+
+class TestUPSChangeStateSCUCLI(unittest.TestCase):
+    def setUp(self):
+        self.scu_patch = mock.patch("tdwii_plus_examples.cli.upschangestatescu.UPSPullNActionSCU")
+        self.mock_scu_class = self.scu_patch.start()
+        self.mock_scu = self.mock_scu_class.return_value
+
+    def tearDown(self):
+        self.scu_patch.stop()
+
+    def run_main_with_args(self, args):
+        with mock.patch.object(sys, "argv", args), mock.patch("sys.stdout", new_callable=StringIO) as mock_stdout:
+            try:
+                upschangestatescu.main()
+            except SystemExit as e:
+                return e.code, mock_stdout.getvalue()
+            return 0, mock_stdout.getvalue()
+
+    def test_claim_ups_success(self):
+        self.mock_scu.claim_ups.return_value = "1.2.3.4"
+        exit_code, output = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "IN PROGRESS"])
+        self.mock_scu.claim_ups.assert_called_once_with("1.2.3.4.5")
+        self.assertEqual(exit_code, 0)
+        self.assertIn("Transaction UID: 1.2.3.4", output)
+
+    def test_cancel_ups_success(self):
+        self.mock_scu.cancel_ups.return_value = True
+        exit_code, output = self.run_main_with_args(
+            ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "CANCELED", "2.3.4.5.6"]
+        )
+        self.mock_scu.cancel_ups.assert_called_once_with("1.2.3.4.5", "2.3.4.5.6")
+        self.assertEqual(exit_code, 0)
+
+    def test_complete_ups_success(self):
+        self.mock_scu.complete_ups.return_value = True
+        exit_code, output = self.run_main_with_args(
+            ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "COMPLETED", "2.3.4.5.6"]
+        )
+        self.mock_scu.complete_ups.assert_called_once_with("1.2.3.4.5", "2.3.4.5.6")
+        self.assertEqual(exit_code, 0)
+
+    def test_missing_transaction_uid_for_cancel(self):
+        with mock.patch.object(sys, "argv", ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "CANCELED"]):
+            with self.assertRaises(SystemExit) as cm:
+                upschangestatescu.main()
+            self.assertEqual(cm.exception.code, 2)  # parser error code value
+
+    def test_transaction_uid_given_for_in_progress(self):
+        with mock.patch.object(
+            sys, "argv", ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "IN PROGRESS", "2.3.4.5.6"]
+        ):
+            with self.assertRaises(SystemExit) as cm:
+                upschangestatescu.main()
+            self.assertEqual(cm.exception.code, 2)  # parser error code value
+
+    def test_echo_success(self):
+        self.mock_scu.verify.return_value = mock.Mock(status_category="Success")
+        exit_code, output = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "--echo"])
+        self.mock_scu.verify.assert_called_once()
+        self.assertEqual(exit_code, 0)
+
+    def test_echo_failure(self):
+        self.mock_scu.verify.return_value = mock.Mock(status_category="Failure", status_description="Failed")
+        exit_code, output = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "--echo"])
+        self.mock_scu.verify.assert_called_once()
+        self.assertEqual(exit_code, 1)
diff --git a/tdwii_plus_examples/tests/test_basescu_unit.py b/tdwii_plus_examples/tests/test_basescu_unit.py
index 8e02dff..66ef9fe 100644
--- a/tdwii_plus_examples/tests/test_basescu_unit.py
+++ b/tdwii_plus_examples/tests/test_basescu_unit.py
@@ -95,8 +95,8 @@ def test_associate_rejection(self):
         with patch.object(self.base_scu.assoc, "is_established", new_callable=PropertyMock) as mock_is_established:
             # Test association rejection
             mock_is_established.return_value = False
-            assoc_result = self.base_scu._associate()
-            self.assertEqual(assoc_result.status, "Error")
+            success, details = self.base_scu._associate()
+            self.assertEqual(details.status, "Error")
         self.test_logger.info("Test _associate with rejection")
 
     @parameterized.expand(
@@ -109,9 +109,9 @@ def test_associate_rejection(self):
     def test_associate_ae_params_not_set(self, called_ip, called_port):
         base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
 
-        assoc_result = base_scu._associate()
-        self.assertEqual(assoc_result.status, "Error")
-        self.assertEqual(str(assoc_result.description), "Called AE parameters not set")
+        success, details = base_scu._associate()
+        self.assertEqual(details.status, "Error")
+        self.assertEqual(str(details.description), "Called AE parameters not set")
         self.test_logger.info(
             f"Test _associate with called AE parameters not set with called_ip={called_ip}, called_port={called_port}"
         )
@@ -122,13 +122,13 @@ def test_associate_ae_params_not_set(self, called_ip, called_port):
             (
                 [MagicMock(abstract_syntax="1.2.840.10008.1.1"), MagicMock(abstract_syntax="1.2.840.10008.5.1.4.34.6.2")],
                 [MagicMock(abstract_syntax="1.2.840.10008.1.1"), MagicMock(abstract_syntax="1.2.840.10008.5.1.4.34.6.2")],
-                "Success",
+                True,
             ),
             # Test case where some contexts are not accepted
             (
                 [MagicMock(abstract_syntax="1.2.840.10008.1.1"), MagicMock(abstract_syntax="1.2.840.10008.5.1.4.34.6.2")],
                 [MagicMock(abstract_syntax="1.2.840.10008.1.1")],
-                "Warning",
+                False,
             ),
         ]
     )
@@ -142,7 +142,7 @@ def test_all_contexts_accepted(self, requested_contexts, accepted_contexts, expe
         type(assoc).accepted_contexts = PropertyMock(return_value=accepted_contexts)
 
         if expected_result == "Warning":
-            assoc_result = self.base_scu._all_contexts_accepted(assoc)
+            assoc_result = self.base_scu._check_accepted_sop_classes(assoc)
             self.assertEqual(assoc_result.status, expected_result)
             self.assertEqual(len(assoc_result.accepted_sop_classes), len(accepted_contexts))
             # Check the log messages in the memory handler
@@ -150,7 +150,7 @@ def test_all_contexts_accepted(self, requested_contexts, accepted_contexts, expe
             log_messages = [record.getMessage() for record in self.memory_handler.buffer]
             self.test_logger.debug("Warning expected - log messages: %s", log_messages)
         else:
-            assoc_result = self.base_scu._all_contexts_accepted(assoc)
+            assoc_result = self.base_scu._check_accepted_sop_classes(assoc)
             self.assertEqual(assoc_result.status, expected_result)
             self.memory_handler.flush()
             log_messages = [record.getMessage() for record in self.memory_handler.buffer]
@@ -271,6 +271,14 @@ def test_handle_response_unknown(self, mock_get_status_description):
     @patch("tdwii_plus_examples.basescu.BaseSCU._handle_response")
     def test_verify_success(self, mock_handle_response, mock_associate):
         # Mock the association and response handling
+        mock_associate.return_value = (
+            True,
+            BaseSCU.AssociationResult(
+                status="Success",
+                description="All requested SOP Classes were accepted",
+                accepted_sop_classes=[UID("1.2.840.10008.1.1")],
+            ),
+        )
         mock_assoc_instance = MagicMock()
         mock_assoc_instance.send_c_echo.return_value = MagicMock()
         mock_assoc_instance.release.return_value = None
@@ -317,10 +325,13 @@ def test_verify_context_warning(self, name, accepted_sop_classes, expected_abstr
         # Patch the _associate method with the appropriate result
         with patch(
             "tdwii_plus_examples.basescu.BaseSCU._associate",
-            return_value=BaseSCU.AssociationResult(
-                status="Warning",
-                description="Some SOP classes were refused",
-                accepted_sop_classes=accepted_sop_classes,
+            return_value=(
+                False,
+                BaseSCU.AssociationResult(
+                    status="Warning",
+                    description="Some SOP classes were refused",
+                    accepted_sop_classes=accepted_sop_classes,
+                ),
             ),
         ) as mock_associate:
             # Call the verify method
diff --git a/tdwii_plus_examples/tests/test_cstorescu_integration.py b/tdwii_plus_examples/tests/test_cstorescu_integration.py
index 350699b..e2213ad 100644
--- a/tdwii_plus_examples/tests/test_cstorescu_integration.py
+++ b/tdwii_plus_examples/tests/test_cstorescu_integration.py
@@ -55,11 +55,6 @@ def setUp(self):
         )
 
     def tearDown(self):
-        # Remove the temporary directory and its contents
-        files_in_temp_dir = os.listdir(self.temp_dir)
-        shutil.rmtree(self.temp_dir)
-        self.test_logger.info(f"Removed the temp directory: {self.temp_dir} and its contents: {files_in_temp_dir}")
-
         # Try to gracefully terminate the process.
         self.test_logger.info(f"Terminating process with PID: {self.process.pid}")
         try:
@@ -91,6 +86,11 @@ def tearDown(self):
             if self.stdout_thread:
                 self.stdout_thread.join(timeout=1.0)
 
+        # Remove the temporary directory and its contents
+        files_in_temp_dir = os.listdir(self.temp_dir)
+        shutil.rmtree(self.temp_dir)
+        self.test_logger.info(f"Removed the temp directory: {self.temp_dir} and its contents: {files_in_temp_dir}")
+
         # Remove and close the memory handler for scu and test loggers
         for logger, handler in [(self.scu_logger, self.memory_handler), (self.test_logger, self.stream_handler)]:
             logger.removeHandler(handler)
@@ -99,7 +99,8 @@ def tearDown(self):
     @parameterized.expand(
         [
             ("all_contexts_accepted", "SecondaryCaptureImageStorage,CTImageStorage", 3),
-            ("some_contexts_rejected", "CTImageStorage", 0),
+            ("required_contexts_rejected", "CTImageStorage", 0),
+            ("non_required_contexts_rejected", "SecondaryCaptureImageStorage", 3),
         ]
     )
     def test_verif_storage(self, name, supported_contexts, expected_success_count):
diff --git a/tdwii_plus_examples/tests/test_cstorescu_unit.py b/tdwii_plus_examples/tests/test_cstorescu_unit.py
index 22a0eba..c5102d3 100644
--- a/tdwii_plus_examples/tests/test_cstorescu_unit.py
+++ b/tdwii_plus_examples/tests/test_cstorescu_unit.py
@@ -3,8 +3,14 @@
 from unittest import mock
 
 from pydicom import Dataset
-from pydicom.uid import UID, generate_uid
+from pydicom.uid import ExplicitVRLittleEndian, generate_uid
 from pynetdicom.presentation import build_context
+from pynetdicom.sop_class import (
+    CTImageStorage,
+    MRImageStorage,
+    SecondaryCaptureImageStorage,
+    StudyRootQueryRetrieveInformationModelFind,
+)
 
 from tdwii_plus_examples.cstorescu import CStoreSCU
 
@@ -30,7 +36,9 @@ def test_store_instances(self, mock_associate, mock_handle_response):
         """Test storing multiple DICOM instances."""
         # Create 2 empty instances
         ds_1 = Dataset()
+        ds_1.SOPClassUID = CTImageStorage
         ds_2 = Dataset()
+        ds_2.SOPClassUID = CTImageStorage
         instances = [ds_1, ds_2]
 
         # Mock the association instance
@@ -40,7 +48,7 @@ def test_store_instances(self, mock_associate, mock_handle_response):
         # Mock the send_c_store method to return a success status
         mock_assoc_instance.send_c_store.return_value = Dataset()
         # Mock _associate to return the mock association instance
-        mock_associate.return_value = mock_assoc_instance
+        mock_associate.return_value = True, mock_assoc_instance
         # Assign the mock association instance to the SCU's assoc attribute
         self.cstore_scu.assoc = mock_assoc_instance
         # Mock the _handle_response method to return a success status
@@ -60,8 +68,10 @@ def test_store_instances_partial(self, mock_associate, mock_handle_response):
         """Test storing multiple DICOM instances with partial success."""
         # Create 2 minimal DICOM instances
         ds_1 = Dataset()
+        ds_1.SOPClassUID = CTImageStorage
         ds_1.SOPInstanceUID = generate_uid()
         ds_2 = Dataset()
+        ds_2.SOPClassUID = CTImageStorage
         ds_2.SOPInstanceUID = generate_uid()
         instances = [ds_1, ds_2]
 
@@ -72,7 +82,7 @@ def test_store_instances_partial(self, mock_associate, mock_handle_response):
         # Mock the send_c_store method to return a success status
         mock_assoc_instance.send_c_store.return_value = Dataset()
         # Mock _associate to return the mock association instance
-        mock_associate.return_value = mock_assoc_instance
+        mock_associate.return_value = True, mock_assoc_instance
         # Assign the mock association instance to the SCU's assoc attribute
         self.cstore_scu.assoc = mock_assoc_instance
         # Set up side_effect for mock_handle_response to return different statuses
@@ -95,7 +105,7 @@ def test_store_instances_association_failure(self, mock_associate):
         """Test storing instances with association failure."""
         # Create 1 'valid' DICOM instance
         ds = Dataset()
-        ds.SOPClassUID = UID("1.2.840.10008.5.1.4.1.1.2")  # CT Image Storage SOP Class UID
+        ds.SOPClassUID = CTImageStorage
         ds.SOPInstanceUID = generate_uid()
         instances = [ds]
 
@@ -106,8 +116,8 @@ def test_store_instances_association_failure(self, mock_associate):
 
         # Mock the association result to simulate an association failure
         mock_assoc_result = mock.Mock()
-        mock_assoc_result.status = "Error"  # Set status to "Error"
-        mock_associate.return_value = mock_assoc_result
+        mock_assoc_result = False
+        mock_associate.return_value = False, mock_assoc_result
 
         # Call the method
         success_count = self.cstore_scu.store_instances(instances)
@@ -116,44 +126,45 @@ def test_store_instances_association_failure(self, mock_associate):
         self.assertEqual(success_count, 0)
 
     @mock.patch("tdwii_plus_examples.cstorescu.CStoreSCU._associate")
-    def test_store_instances_association_warning(self, mock_associate):
-        """Test storing instances with association failure."""
-        # Create 1 'valid' DICOM instance
-        ds = Dataset()
-        ds.SOPClassUID = UID("1.2.840.10008.5.1.4.1.1.2")  # CT Image Storage SOP Class UID
-        ds.SOPInstanceUID = generate_uid()
-        instances = [ds]
+    def test_store_instances_required_sop_classes(self, mock_associate):
+        """Test that required SOP classes are correctly extracted from instances."""
+        # Create instances with different SOP Class UIDs
+        ds1 = Dataset()
+        ds1.SOPClassUID = CTImageStorage
+        ds2 = Dataset()
+        ds2.SOPClassUID = MRImageStorage
+        instances = [ds1, ds2]
 
-        # Mock the contexts attribute
-        self.cstore_scu.contexts = [
-            build_context("1.2.840.10008.1.1"),
-            build_context("1.2.840.10008.5.1.4.1.1.2"),
-        ]  # Verification and CT Image Storage
-        # Mock the association directly on the CStoreSCU instance
+        # Mock the association result
+        mock_assoc_result = mock.Mock()
+        mock_assoc_result.status = True  # Assume successful association for this test
+        mock_assoc_result.accepted_sop_classes = [ds1.SOPClassUID, ds2.SOPClassUID]  # Mock accepted SOP classes
+        mock_associate.return_value = (True, mock_assoc_result)
+
+        # Mock the association and release method
         self.cstore_scu.assoc = mock.Mock()
-        # Mock the release method
         self.cstore_scu.assoc.release = mock.Mock()
 
-        # Mock the association result to simulate an association not supporting all requested contexts
-        mock_assoc_result = mock.Mock()
-        mock_assoc_result.status = "Warning"
-        mock_assoc_result.accepted_sop_classes = ["1.2.840.10008.1.1"]
-
-        # Mock _associate to return the mock association result within a tuple
-        mock_associate.return_value = mock_assoc_result
+        # Mock the association, release, and send_c_store methods
+        self.cstore_scu.assoc = mock.Mock()
+        self.cstore_scu.assoc.release = mock.Mock()
+        status_dataset = Dataset()
+        status_dataset.Status = 0x0000  # Success status
+        self.cstore_scu.assoc.send_c_store.return_value = status_dataset
 
         # Call the method
-        success_count = self.cstore_scu.store_instances(instances)
+        self.cstore_scu.store_instances(instances)
 
-        # Assert storage failed
-        self.assertEqual(success_count, 0)
+        # Assert that _associate was called with the correct required_sop_classes
+        expected_required_sop_classes = [ds1.SOPClassUID, ds2.SOPClassUID]
+        mock_associate.assert_called_once_with(required_sop_classes=expected_required_sop_classes)
 
     def test_set_contexts_valid(self):
         """Test set_contexts."""
         # Valid contexts
         storage_contexts = [
-            build_context("1.2.840.10008.5.1.4.1.1.2"),  # CT Image Storage
-            build_context("1.2.840.10008.5.1.4.1.1.4"),  # MR Image Storage
+            build_context(MRImageStorage),
+            build_context(MRImageStorage),
         ]
         self.cstore_scu.set_contexts(storage_contexts)
         self.assertEqual(len(self.cstore_scu.contexts), 2)
@@ -163,11 +174,11 @@ def test_set_contexts_valid(self):
 
     def test_set_contexts_invalid(self):
         """Test set_contexts with an invalid context."""
-        qr_abstract__syntax = "1.2.840.10008.5.1.4.1.2.2.1"  # not a storage context
-        invalid_context = build_context(qr_abstract__syntax)
+        qr_abstract_syntax = StudyRootQueryRetrieveInformationModelFind  # not a storage context
+        invalid_context = build_context(qr_abstract_syntax)
 
         with self.assertRaisesRegex(
-            ValueError, f"Only Storage Presentation Contexts are allowed. Invalid contexts: \\['{qr_abstract__syntax}'\\]"
+            ValueError, f"Only Storage Presentation Contexts are allowed. Invalid contexts: \\['{qr_abstract_syntax}'\\]"
         ):
             self.cstore_scu.set_contexts([invalid_context])
 
@@ -175,16 +186,16 @@ def test_set_contexts_from_files(self):
         """Test set_contexts_from_files."""
         # Create some dummy datasets
         ds1 = Dataset()
-        ds1.SOPClassUID = "1.2.840.10008.5.1.4.1.1.2"  # CT Image Storage
+        ds1.SOPClassUID = CTImageStorage
         ds2 = Dataset()
-        ds2.SOPClassUID = "1.2.840.10008.5.1.4.1.1.4"  # MR Image Storage
+        ds2.SOPClassUID = MRImageStorage
         instances = [ds1, ds2]
 
         self.cstore_scu.set_contexts_from_files(instances)
 
         self.assertEqual(len(self.cstore_scu.contexts), 2)
-        self.assertIn(build_context(ds1.SOPClassUID, "1.2.840.10008.1.2.1"), self.cstore_scu.contexts)
-        self.assertIn(build_context(ds2.SOPClassUID, "1.2.840.10008.1.2.1"), self.cstore_scu.contexts)
+        self.assertIn(build_context(ds1.SOPClassUID, ExplicitVRLittleEndian), self.cstore_scu.contexts)
+        self.assertIn(build_context(ds2.SOPClassUID, ExplicitVRLittleEndian), self.cstore_scu.contexts)
         # Check if requested contexts were updated (including verification)
         self.assertEqual(len(self.cstore_scu.ae.requested_contexts), 3)
 
@@ -194,12 +205,12 @@ def test_validate_contexts_empty(self):
 
     def test_validate_contexts_valid(self):
         """Test _validate_contexts with valid contexts."""
-        storage_contexts = [build_context(uid) for uid in ["1.2.840.10008.5.1.4.1.1.2", "1.2.840.10008.5.1.4.1.1.7"]]
+        storage_contexts = [build_context(uid) for uid in [CTImageStorage, SecondaryCaptureImageStorage]]
         self.assertEqual(self.cstore_scu._validate_contexts(storage_contexts), storage_contexts)
 
     def test_validate_contexts_invalid(self):
         """Test _validate_contexts with invalid contexts."""
-        qr_abstract__syntax = "1.2.840.10008.5.1.4.1.2.2.1"  # not a storage context
+        qr_abstract__syntax = StudyRootQueryRetrieveInformationModelFind  # not a storage context
         contexts = [build_context(qr_abstract__syntax)]
         with self.assertRaisesRegex(
             ValueError, f"Only Storage Presentation Contexts are allowed. Invalid contexts: \\['{qr_abstract__syntax}'\\]"
diff --git a/tdwii_plus_examples/tests/test_upspullnactionscu_integration.py b/tdwii_plus_examples/tests/test_upspullnactionscu_integration.py
new file mode 100644
index 0000000..f5652a0
--- /dev/null
+++ b/tdwii_plus_examples/tests/test_upspullnactionscu_integration.py
@@ -0,0 +1,171 @@
+import logging
+import os
+import shutil
+import signal
+import tempfile
+import unittest
+from logging.handlers import MemoryHandler
+from subprocess import TimeoutExpired
+
+import psutil
+
+from tdwii_plus_examples.tests.utils.generate_sop_instances import generate_ups
+from tdwii_plus_examples.tests.utils.utils import get_configured_logger, start_scp
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+from tdwii_plus_examples.upspushncreatescu import UPSPushNCreateSCU
+
+
+class TestUPSPullNActionSCUIntegration(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        # Set up the logger for the UPSPullNActionSCU to DEBUG level
+        # with a memory handler to store up to 100 log messages
+        cls.scu_logger, cls.memory_handler = get_configured_logger(
+            "upspullnactionscu", level=logging.DEBUG, handler_class=MemoryHandler, capacity=100
+        )
+
+        # Set up the logger for this test to DEBUG level
+        cls.test_logger, cls.stream_handler = get_configured_logger("test_upspullnactionscu", level=logging.DEBUG)
+
+        # Create a temporary directory for UPSSCP staging area
+        cls.temp_dir = tempfile.mkdtemp()
+        cls.test_logger.info(f"{cls.temp_dir} directory created")
+
+        # Start the UPS SCP Server (replace with your actual SCP command)
+        command = [
+            "./tdwii_plus_examples/cli/upsscp/upsscp.py",
+            "-v",
+            "--database-location",
+            f"{cls.temp_dir}/test.sqlite",
+            "--instance-location",
+            cls.temp_dir,
+        ]
+
+        cls.upsscp_started, cls.process, cls.stdout_thread = start_scp(command, cls.test_logger)
+        cls.test_logger.info(f"Started process with PID: {cls.process.pid}")
+
+        # Create the UPSPullNActionSCU instance
+        cls.scu = UPSPullNActionSCU(
+            calling_ae_title="UPSPULLSCU",
+            called_ae_title="ANYSCP",
+            called_ip="localhost",
+            called_port=11114,
+            logger=cls.scu_logger,
+        )
+
+    @classmethod
+    def tearDownClass(cls):
+        # Try to gracefully terminate the process.
+        cls.test_logger.info(f"Terminating process with PID: {cls.process.pid}")
+        try:
+            cls.process.terminate()
+            cls.process.wait(timeout=1)
+            cls.test_logger.info("Process terminated gracefully.")
+        except (ChildProcessError, TimeoutExpired):
+            cls.test_logger.warning("Process termination timed out or process already finished.")
+
+        # Try to kill child processes.
+        try:
+            parent = psutil.Process(cls.process.pid)
+            for child in parent.children(recursive=True):
+                child.kill()
+            cls.test_logger.info("Child processes terminated.")
+        except psutil.NoSuchProcess:
+            cls.test_logger.warning("Error killing subprocesses. Parent process not found. Probably already terminated.")
+
+        # Try to forcefully kill the process.
+        try:
+            os.kill(cls.process.pid, signal.SIGKILL)
+            cls.process.wait(timeout=1)  # Ensure process termination
+            cls.test_logger.info("Process forcefully terminated.")
+        except OSError:
+            cls.test_logger.warning("Error killing process. Probably already terminated.")
+
+        finally:
+            # Ensure the stdout thread has finished
+            if cls.stdout_thread:
+                cls.stdout_thread.join(timeout=1.0)
+
+        # Remove the temporary directory and its contents
+        files_in_temp_dir = os.listdir(cls.temp_dir)
+        shutil.rmtree(cls.temp_dir)
+        cls.test_logger.info(f"Removed the temp directory: {cls.temp_dir} and its contents: {files_in_temp_dir}")
+
+        # Remove and close the memory handler for scu and test loggers
+        for logger, handler in [(cls.scu_logger, cls.memory_handler), (cls.test_logger, cls.stream_handler)]:
+            logger.removeHandler(handler)
+            handler.close()
+
+    def test_claim_and_complete_ups(self):
+        """Integration test for claiming and completing a UPS instance."""
+
+        if not self.upsscp_started:
+            self.fail("upsscp server failed to start.")
+            return
+
+        # Create a UPS in the SCP
+        ups_instance = generate_ups()
+        scu = UPSPushNCreateSCU(
+            calling_ae_title="UPSPUSH",
+            called_ae_title="UPSSCP",
+            called_ip="localhost",
+            called_port=11114,
+            logger=self.scu_logger,
+        )
+        num_created_instances = scu.create_ups_instances([ups_instance])
+
+        if num_created_instances != 1:
+            self.fail("failed to create UPS.")
+            return
+
+        sop_instance_uid = ups_instance.SOPInstanceUID
+
+        # Try to claim the UPS
+        transaction_uid = self.scu.claim_ups(sop_instance_uid)
+        self.assertIsNotNone(transaction_uid)
+        self.test_logger.info(f"Claimed UPS with transaction UID: {transaction_uid}")
+
+        # Try to complete the UPS if it was claimed
+        if transaction_uid:
+            result = self.scu.complete_ups(sop_instance_uid, transaction_uid)
+            self.assertTrue(result)
+            self.test_logger.info("Completed UPS successfully.")
+
+    def test_claim_and_cancel_ups(self):
+        """Integration test for claiming and completing a UPS instance."""
+
+        if not self.upsscp_started:
+            self.fail("upsscp server failed to start.")
+            return
+
+        # Create a UPS in the SCP
+        ups_instance = generate_ups()
+        scu = UPSPushNCreateSCU(
+            calling_ae_title="UPSPUSH",
+            called_ae_title="UPSSCP",
+            called_ip="localhost",
+            called_port=11114,
+            logger=self.scu_logger,
+        )
+        num_created_instances = scu.create_ups_instances([ups_instance])
+
+        if num_created_instances != 1:
+            self.fail("failed to create UPS.")
+            return
+
+        sop_instance_uid = ups_instance.SOPInstanceUID
+
+        # Try to claim the UPS
+        transaction_uid = self.scu.claim_ups(sop_instance_uid)
+        self.assertIsNotNone(transaction_uid)
+        self.test_logger.info(f"Claimed UPS with transaction UID: {transaction_uid}")
+
+        # Try to complete the UPS if it was claimed
+        if transaction_uid:
+            result = self.scu.cancel_ups(sop_instance_uid, transaction_uid)
+            self.assertTrue(result)
+            self.test_logger.info("Canceled UPS successfully.")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tdwii_plus_examples/tests/test_upspullnactionscu_unit.py b/tdwii_plus_examples/tests/test_upspullnactionscu_unit.py
new file mode 100644
index 0000000..bcacec9
--- /dev/null
+++ b/tdwii_plus_examples/tests/test_upspullnactionscu_unit.py
@@ -0,0 +1,165 @@
+import logging
+import unittest
+from unittest import mock
+
+from parameterized import parameterized
+from pydicom import Dataset
+from pydicom.uid import generate_uid
+
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+
+LOGGER = logging.getLogger(__name__)
+
+
+class TestUPSPullNActionSCU(unittest.TestCase):
+    def setUp(self):
+        """Setup before each test."""
+        # Create UPSPullNActionSCU instance
+        self.scu = UPSPullNActionSCU(
+            calling_ae_title="CALLING_AE",
+            called_ip="127.0.0.1",
+            called_port=11112,
+            called_ae_title="CALLED_AE",
+            logger=LOGGER,
+        )
+
+    @parameterized.expand(
+        [
+            ("success", "Success", True),
+            ("failure", "Failure", False),
+        ]
+    )
+    @mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._handle_response")
+    @mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._associate")
+    def test_claim_ups(self, name, status_category, expected, mock_associate, mock_handle_response):
+        """Test claiming a UPS with parameterized status_category."""
+
+        # Mock the association instance
+        mock_assoc_instance = mock.Mock()
+        # Mock the release method
+        mock_assoc_instance.release = mock.Mock()
+        # Mock the send_n_action method to return a tuple of (status, dataset)
+        mock_assoc_instance.send_n_action.return_value = (Dataset(), Dataset())
+        # Mock _associate to return the mock association instance
+        mock_associate.return_value = True, mock_assoc_instance
+        # Assign the mock association instance to the SCU's assoc attribute
+        self.scu.assoc = mock_assoc_instance
+        # Mock the _handle_response method return value status
+        mock_handle_response.return_value = mock.Mock(status_category=status_category)
+
+        sop_instance_uid = generate_uid()
+        transaction_uid = self.scu.claim_ups(sop_instance_uid)
+
+        # Check if transaction UID is generated and stored
+        if expected:
+            # On success, transaction_uid should be stored and not None
+            self.assertIsNotNone(transaction_uid)
+            self.assertEqual(self.scu.ups_transaction_info[sop_instance_uid], transaction_uid)
+        else:
+            # On failure, transaction_uid should be None and not stored
+            self.assertIsNone(transaction_uid)
+            self.assertNotIn(sop_instance_uid, self.scu.ups_transaction_info)
+
+        # Assert _associate was called
+        mock_associate.assert_called_once()
+        # Assert send_n_action was called
+        self.assertTrue(mock_assoc_instance.send_n_action.called)
+        # Assert _handle_response was called
+        mock_handle_response.assert_called_once()
+
+    @parameterized.expand(
+        [
+            ("success", "Success", True),
+            ("failure", "Failure", False),
+        ]
+    )
+    @mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._handle_response")
+    @mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._associate")
+    def test_complete_ups(self, name, status_category, expected, mock_associate, mock_handle_response):
+        """Test completing a UPS with parameterized status_category."""
+
+        # Mock the association instance
+        mock_assoc_instance = mock.Mock()
+        mock_assoc_instance.release = mock.Mock()
+        mock_assoc_instance.send_n_action.return_value = (Dataset(), Dataset())
+        mock_associate.return_value = True, mock_assoc_instance
+        self.scu.assoc = mock_assoc_instance
+        mock_handle_response.return_value = mock.Mock(status_category=status_category)
+
+        sop_instance_uid = generate_uid()
+        transaction_uid = generate_uid()
+        self.scu.ups_transaction_info[sop_instance_uid] = transaction_uid
+
+        result = self.scu.complete_ups(sop_instance_uid, transaction_uid)
+
+        self.assertEqual(result, expected)
+        mock_associate.assert_called_once()
+        self.assertTrue(mock_assoc_instance.send_n_action.called)
+        mock_handle_response.assert_called_once()
+
+    @parameterized.expand(
+        [
+            ("success", "Success", True),
+            ("failure", "Failure", False),
+        ]
+    )
+    @mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._handle_response")
+    @mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._associate")
+    def test_cancel_ups(self, name, status_category, expected, mock_associate, mock_handle_response):
+        """Test canceling a UPS with parameterized status_category."""
+
+        # Mock the association instance
+        mock_assoc_instance = mock.Mock()
+        mock_assoc_instance.release = mock.Mock()
+        mock_assoc_instance.send_n_action.return_value = (Dataset(), Dataset())
+        mock_associate.return_value = True, mock_assoc_instance
+        self.scu.assoc = mock_assoc_instance
+        mock_handle_response.return_value = mock.Mock(status_category=status_category)
+
+        sop_instance_uid = generate_uid()
+        transaction_uid = generate_uid()
+        self.scu.ups_transaction_info[sop_instance_uid] = transaction_uid
+
+        result = self.scu.cancel_ups(sop_instance_uid, transaction_uid)
+
+        self.assertEqual(result, expected)
+        mock_associate.assert_called_once()
+        self.assertTrue(mock_assoc_instance.send_n_action.called)
+        mock_handle_response.assert_called_once()
+
+    def test_complete_ups_missing_transaction_uid(self):
+        """Test complete_ups when transaction_uid is missing from ups_transaction_info and not passed as arg."""
+        sop_instance_uid = generate_uid()
+        # Do not set transaction_uid in ups_transaction_info
+
+        with (
+            mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._handle_response") as mock_handle_response,
+            mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._associate") as mock_associate,
+        ):
+            result = self.scu.complete_ups(sop_instance_uid)
+            self.assertFalse(result)
+            mock_associate.assert_not_called()
+            mock_handle_response.assert_not_called()
+
+    def test_complete_ups_transaction_uid_passed_as_arg(self):
+        """Test complete_ups when transaction_uid is not in ups_transaction_info but is passed as an argument."""
+        sop_instance_uid = generate_uid()
+        transaction_uid = generate_uid()
+        # Do not set transaction_uid in ups_transaction_info
+
+        with (
+            mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._handle_response") as mock_handle_response,
+            mock.patch("tdwii_plus_examples.upspullnactionscu.UPSPullNActionSCU._associate") as mock_associate,
+        ):
+            mock_assoc_instance = mock.Mock()
+            mock_assoc_instance.release = mock.Mock()
+            mock_assoc_instance.send_n_action.return_value = (Dataset(), Dataset())
+            mock_associate.return_value = True, mock_assoc_instance
+            self.scu.assoc = mock_assoc_instance
+            mock_handle_response.return_value = mock.Mock(status_category="Success")
+
+            result = self.scu.complete_ups(sop_instance_uid, transaction_uid)
+            self.assertTrue(result)
+            mock_associate.assert_called_once()
+            self.assertTrue(mock_assoc_instance.send_n_action.called)
+            mock_handle_response.assert_called_once()
diff --git a/tdwii_plus_examples/tests/test_upspushncreatescu_unit.py b/tdwii_plus_examples/tests/test_upspushncreatescu_unit.py
index a203c2b..3ac25ba 100644
--- a/tdwii_plus_examples/tests/test_upspushncreatescu_unit.py
+++ b/tdwii_plus_examples/tests/test_upspushncreatescu_unit.py
@@ -4,6 +4,7 @@
 
 from pydicom import Dataset
 from pydicom.uid import UID, generate_uid
+from pynetdicom.sop_class import UnifiedProcedureStepPush
 
 from tdwii_plus_examples.upspushncreatescu import UPSPushNCreateSCU
 
@@ -29,10 +30,10 @@ def test_create_ups_instances(self, mock_associate, mock_handle_response):
         """Test creating multiple valid UPS instances."""
         # Create 2 'valid' UPS instances
         ds_1 = Dataset()
-        ds_1.SOPClassUID = UID("1.2.840.10008.5.1.4.34.6.1")  # UPS Push SOP Class UID
+        ds_1.SOPClassUID = UnifiedProcedureStepPush
         ds_1.SOPInstanceUID = generate_uid()
         ds_2 = Dataset()
-        ds_2.SOPClassUID = UID("1.2.840.10008.5.1.4.34.6.1")  # UPS Push SOP Class UID
+        ds_2.SOPClassUID = UnifiedProcedureStepPush
         ds_2.SOPInstanceUID = generate_uid()
         instances = [ds_1, ds_2]
 
@@ -43,7 +44,7 @@ def test_create_ups_instances(self, mock_associate, mock_handle_response):
         # Mock the send_n_create method to return a success status and an empty dataset.
         mock_assoc_instance.send_n_create.return_value = (Dataset(), Dataset())
         # Mock _associate to return the mock association instance, preventing a real association.
-        mock_associate.return_value = mock_assoc_instance
+        mock_associate.return_value = True, mock_assoc_instance
         # Assign the mock association instance to the SCU's assoc attribute.
         self.upspush_ncreate_scu.assoc = mock_assoc_instance
         # Mock the _handle_response method to return a success status.
@@ -78,7 +79,7 @@ def test_create_ups_instances_association_failure(self, mock_associate):
         """Test creating UPS instances with association failure."""
         # Create 1 'valid' UPS instance
         ds = Dataset()
-        ds.SOPClassUID = UID("1.2.840.10008.5.1.4.34.6.1")  # UPS Push SOP Class UID
+        ds.SOPClassUID = UnifiedProcedureStepPush
         ds.SOPInstanceUID = generate_uid()
         instances = [ds]
 
@@ -87,7 +88,7 @@ def test_create_ups_instances_association_failure(self, mock_associate):
         # Mock the release method.
         mock_assoc_instance.release = mock.Mock()
         # Mock _associate to return the mock association instance, preventing a real association.
-        mock_associate.return_value = mock_assoc_instance
+        mock_associate.return_value = False, mock_assoc_instance
         # Set the status to "Error" to simulate an association failure.
         mock_assoc_instance.status = "Error"
 
diff --git a/tdwii_plus_examples/tests/test_upswatchnactionscu_unit.py b/tdwii_plus_examples/tests/test_upswatchnactionscu_unit.py
index 91f27ff..107c8a9 100644
--- a/tdwii_plus_examples/tests/test_upswatchnactionscu_unit.py
+++ b/tdwii_plus_examples/tests/test_upswatchnactionscu_unit.py
@@ -159,14 +159,17 @@ def test_toggle_subscription_context_warning(
         # Patch the _associate method with the appropriate result
         with patch(
             "tdwii_plus_examples.basescu.BaseSCU._associate",
-            return_value=UPSWatchNActionSCU.AssociationResult(
-                status=assoc_status,
-                description="Some SOP classes were refused"
-                if assoc_status == "Warning"
-                else "Association error"
-                if assoc_status == "Error"
-                else "Association successful",
-                accepted_sop_classes=accepted_sop_classes,
+            return_value=(
+                assoc_status == "Success",
+                UPSWatchNActionSCU.AssociationResult(
+                    status=assoc_status,
+                    description="Some SOP classes were refused"
+                    if assoc_status == "Warning"
+                    else "Association error"
+                    if assoc_status == "Error"
+                    else "Association successful",
+                    accepted_sop_classes=accepted_sop_classes,
+                ),
             ),
         ) as mock_associate:
             # Call the _toggle_subscription method
diff --git a/tdwii_plus_examples/upspullnactionscu.py b/tdwii_plus_examples/upspullnactionscu.py
new file mode 100644
index 0000000..0b51d1b
--- /dev/null
+++ b/tdwii_plus_examples/upspullnactionscu.py
@@ -0,0 +1,224 @@
+from pydicom import Dataset
+from pydicom.uid import UID, generate_uid
+from pynetdicom.sop_class import UnifiedProcedureStepPull, UnifiedProcedureStepPush
+from pynetdicom.status import UNIFIED_PROCEDURE_STEP_SERVICE_CLASS_STATUS
+
+from tdwii_plus_examples._ups_action_type_id import UPSActionTypeID
+from tdwii_plus_examples.basescu import BaseSCU
+
+# UPS Procedure Step State constants
+SCHEDULED: str = "SCHEDULED"
+IN_PROGRESS: str = "IN PROGRESS"
+CANCELED: str = "CANCELED"
+COMPLETED: str = "COMPLETED"
+
+ALLOWED_STEP_STATES = (IN_PROGRESS, CANCELED, COMPLETED)
+
+
+class UPSPullNActionSCU(BaseSCU):
+    """
+    A subclass of BaseSCU that implements the N-ACTION DIMSE operation
+    for the Unified Procedure Step Pull SOP Class.
+
+    This class is derived from BaseSCU and implements the necessary methods
+    to request UPS Pull presentation contexts and perform N-ACTION requests
+    to change the state of a UPS instance.
+
+    Attributes:
+        logger: A logger instance.
+        calling_ae_title: The title of the calling AE.
+        called_ae_title: The title of the called AE.
+        called_ip: The IP address or hostname of the called AE.
+        called_port: The port number of the called AE.
+    """
+
+    def __init__(self, logger=None, calling_ae_title=None, called_ip=None, called_port=None, called_ae_title=None):
+        """
+        Initializes a UPSPullNActionSCU instance.
+
+        Parameters
+        ----------
+        logger : Logger, optional
+            The logger instance for logging messages.
+        calling_ae_title : str, optional
+            The AE title of the calling AE.
+        called_ip : str, optional
+            The IP address of the called AE.
+        called_port : int, optional
+            The port number of the called AE.
+        called_ae_title : str, optional
+            The AE title of the called AE.
+        """
+
+        super().__init__(
+            logger,
+            calling_ae_title=calling_ae_title,
+            called_ip=called_ip,
+            called_port=called_port,
+            called_ae_title=called_ae_title,
+        )
+        # Dictionary to store transaction UIDs for each UPS instance IN PROGRESS
+        self.ups_transaction_info = {}
+
+        self.logger.debug("UPSPullNActionSCU initialized")
+
+    def _add_requested_context(self):
+        """Adds the Unified Procedure Step Pull SOP Class presentation context."""
+        super()._add_requested_context()
+        self.ae.add_requested_context(UnifiedProcedureStepPull)
+        self.logger.debug(f"UPS Pull Presentation context added: {UnifiedProcedureStepPull}")
+
+    def _get_status_description(self, status_code):
+        """
+        Retrieves the description for a specific UPS status code.
+
+        Args:
+            status_code (int): The status code to look up.
+
+        Returns:
+            str: The description of the status code.
+        """
+        return UNIFIED_PROCEDURE_STEP_SERVICE_CLASS_STATUS.get(status_code, ("Unknown", "Unknown status code"))[1]
+
+    def claim_ups(self, sop_instance_uid):
+        """Claims a SCHEDULED UPS instance by generating a transaction UID, setting its state to IN PROGRESS,
+        and sending an N-ACTION request.
+
+        Args:
+            sop_instance_uid (str): The SOP Instance UID of the UPS to claim.
+
+        Returns:
+            pydicom.uid.UID | None: The transaction UID generated to lock the UPS, or None on failure.
+        """
+        action_info = Dataset()
+
+        # To take control of a SCHEDULED UPS, an SCU shall generate a Transaction UID
+        transaction_uid = generate_uid()
+
+        # and submit a state change to IN PROGRESS
+        action_info.ProcedureStepState = IN_PROGRESS
+
+        # including the Transaction UID in the submission.
+        action_info.TransactionUID = transaction_uid
+
+        result = self._change_ups_state(sop_instance_uid, action_info)
+
+        # The SCU shall record and use the Transaction UID in future N-ACTION and N-SET requests for that UPS instance.
+        if result.status_category == "Success":
+            self.ups_transaction_info[sop_instance_uid] = transaction_uid
+            return transaction_uid
+
+        return None
+
+    def complete_ups(self, sop_instance_uid, transaction_uid=None):
+        """Completes an IN PROGRESS UPS instance.
+
+        Upon completion of an IN PROGRESS UPS it controls, an SCU shall
+        submit a state change to COMPLETED.
+
+        Args:
+            sop_instance_uid (str): The SOP Instance UID of the UPS.
+            transaction_uid (str, optional): The Transaction UID of the UPS.
+                If not provided, the stored transaction UID will be used.
+
+        Returns:
+            bool: True if the UPS was successfully completed, False otherwise.
+        """
+        result = self._close_ups(sop_instance_uid, transaction_uid, COMPLETED)
+        return result.status_category == "Success"
+
+    def cancel_ups(self, sop_instance_uid, transaction_uid=None):
+        """Cancels an IN PROGRESS UPS instance.
+
+        To cancel an IN PROGRESS UPS, an SCU shall submit a state change to CANCELED.
+
+        Args:
+            sop_instance_uid (str): The SOP Instance UID of the UPS.
+            transaction_uid (str, optional): The Transaction UID of the UPS.
+                If not provided, the stored transaction UID will be used.
+
+        Returns:
+            bool: True if the UPS was successfully canceled, False otherwise.
+        """
+        result = self._close_ups(sop_instance_uid, transaction_uid, CANCELED)
+        return result.status_category == "Success"
+
+    def _close_ups(self, sop_instance_uid, transaction_uid, state):
+        """Sets the state of a UPS instance to COMPLETED or CANCELED.
+
+        Args:
+            sop_instance_uid (str): The SOP Instance UID of the UPS.
+            transaction_uid (UID, optional): The transaction UID.
+                If not provided, the stored one will be used.
+            state (str): The desired state of the UPS ("COMPLETED" or "CANCELED").
+
+        Returns:
+            PrimitiveResult: The result of the N-ACTION operation.
+        """
+        if not transaction_uid:
+            try:
+                transaction_uid = self.ups_transaction_info[sop_instance_uid]
+                self.logger.debug(f"Using stored transaction UID: {transaction_uid}")
+            except KeyError:
+                msg = "No Transaction UID found for the given SOP Instance UID"
+                self.logger.error(msg)
+                return self.PrimitiveResult("TransactionUIDFailure", 0xD001, msg, None)
+
+        action_info = Dataset()
+        action_info.ProcedureStepState = state
+        action_info.TransactionUID = transaction_uid
+
+        self.logger.debug(f"Setting UPS state to {state} with transaction UID {transaction_uid}")
+        result = self._change_ups_state(sop_instance_uid, action_info)
+
+        if result.status_category == "Success":
+            self.logger.info(f"Successfully set UPS state to {state}")
+            # Remove stored transaction_uid if present in the dict
+            self.ups_transaction_info.pop(sop_instance_uid, None)
+
+        return result
+
+    def _change_ups_state(self, sop_instance_uid, action_info):
+        """
+        Sends an N-ACTION request to change the state of a UPS instance.
+
+        Args:
+            sop_instance_uid (str): The SOP Instance UID of the UPS to act upon.
+            action_info (pydicom.Dataset): The Change UPS State Action Information
+                see PS3.4 Table CC.2.1-1.
+
+        Returns:
+            PrimitiveResult: A named tuple containing the status category, status code,
+            status description, and response dataset.
+        """
+        # Establish association with the SCP
+        success, details = self._associate(required_sop_classes=[UnifiedProcedureStepPull])
+
+        if details.status == "Error":
+            return self.PrimitiveResult("AssocFailure", 0xD000, details.description, None)
+
+        if details.status == "Warning":
+            accepted_sop_names = [f"[{UID(uid).name}]" for uid in details.accepted_sop_classes]
+            self.logger.warning(f"{details.description} - Accepted SOP Classes: {', '.join(accepted_sop_names)}")
+
+        # Send the N-ACTION request
+        self.logger.debug(f"Sending N-ACTION request for SOP Instance UID: {sop_instance_uid}")
+        rsp_status, action_reply = self.assoc.send_n_action(
+            dataset=action_info,
+            action_type=UPSActionTypeID.CHANGE_UPS_STATE.value,
+            class_uid=UnifiedProcedureStepPull,
+            instance_uid=sop_instance_uid,
+            meta_uid=UnifiedProcedureStepPush,
+        )
+
+        # Handle the response
+        result = self._handle_response(rsp_status, action_reply)
+
+        if result.status_category == "Success":
+            self.logger.info("N-ACTION request successful")
+        else:
+            self.logger.error(f"N-ACTION request failed: {result.status_description}")
+
+        self.assoc.release()
+
+        return result
diff --git a/tdwii_plus_examples/upspushncreatescu.py b/tdwii_plus_examples/upspushncreatescu.py
index ba51412..789cff5 100644
--- a/tdwii_plus_examples/upspushncreatescu.py
+++ b/tdwii_plus_examples/upspushncreatescu.py
@@ -64,10 +64,10 @@ def create_ups_instances(self, instances: list[Dataset]) -> int:
                 f"Some instances were ignored because their SOPClassUID did not match UnifiedProcedureStepPush. "
                 f"Total instances provided: {len(instances)}, valid instances: {len(valid_instances)}"
             )
-        assoc_result = self._associate()
+        success, _ = self._associate(required_sop_classes=[UnifiedProcedureStepPush])
         success_count = 0
 
-        if not self._handle_association_status(assoc_result):
+        if not success:
             return 0
 
         for msg_id, instance in enumerate(valid_instances, start=0):
@@ -91,19 +91,6 @@ def create_ups_instances(self, instances: list[Dataset]) -> int:
 
         return success_count
 
-    def _handle_association_status(self, assoc_result) -> bool:
-        if assoc_result.status == "Error":
-            self.logger.error(f"Association failed: {assoc_result.description}")
-            return False
-        if assoc_result.status == "Warning":
-            accepted_sop_names = [f"[{UID(uid).name}]" for uid in assoc_result.accepted_sop_classes]
-            self.logger.warning(f"{assoc_result.description} - Accepted SOP Classes: {', '.join(accepted_sop_names)}")
-            if "[Unified Procedure Step - Push SOP Class]" not in accepted_sop_names:
-                self.assoc.release()
-                self.logger.error(f"Association failed: {assoc_result.description}")
-                return False
-        return True
-
     def _send_upspushncreate_request(
         self,
         assoc: Association,
diff --git a/tdwii_plus_examples/upswatchnactionscu.py b/tdwii_plus_examples/upswatchnactionscu.py
index d5e075b..0179961 100644
--- a/tdwii_plus_examples/upswatchnactionscu.py
+++ b/tdwii_plus_examples/upswatchnactionscu.py
@@ -9,13 +9,9 @@
 )
 from pynetdicom.status import UNIFIED_PROCEDURE_STEP_SERVICE_CLASS_STATUS
 
+from tdwii_plus_examples._ups_action_type_id import UPSActionTypeID
 from tdwii_plus_examples.basescu import BaseSCU
 
-# Action Type ID constants
-ACTION_TYPE_ID_SUBSCRIBE = 3
-ACTION_TYPE_ID_UNSUBSCRIBE = 4
-ACTION_TYPE_ID_SUSPEND = 5
-
 
 class UPSWatchNActionSCU(BaseSCU):
     """
@@ -158,7 +154,7 @@ def _send_upswatchnaction_request(
         """
         action_info = Dataset()
         action_info.ReceivingAE = self.calling_ae_title
-        if action_type_id == ACTION_TYPE_ID_SUBSCRIBE:
+        if action_type_id == UPSActionTypeID.SUBSCRIBE.value:
             action_info.DeletionLock = str(deletion_lock).upper()
             if matching_keys is not None:
                 instance_uid = UPSFilteredGlobalSubscriptionInstance
@@ -197,17 +193,17 @@ def _toggle_subscription(self, action_type_id, instance_uid=UPSGlobalSubscriptio
             A named tuple containing the status category, status code, status description, and dataset,
             indicating the outcome of the request.
         """
-        assoc_result = self._associate()
+        success, details = self._associate()
 
-        if assoc_result.status == "Error":
-            return self.PrimitiveResult("AssocFailure", 0xD000, assoc_result.description, None)
+        if details.status == "Error":
+            return self.PrimitiveResult("AssocFailure", 0xD000, details.description, None)
 
-        if assoc_result.status == "Warning":
-            accepted_sop_names = [f"[{UID(uid).name}]" for uid in assoc_result.accepted_sop_classes]
-            self.logger.warning(f"{assoc_result.description} - Accepted SOP Classes: {', '.join(accepted_sop_names)}")
+        if details.status == "Warning":
+            accepted_sop_names = [f"[{UID(uid).name}]" for uid in details.accepted_sop_classes]
+            self.logger.warning(f"{details.description} - Accepted SOP Classes: {', '.join(accepted_sop_names)}")
             if "[Unified Procedure Step - Watch SOP Class]" not in accepted_sop_names:
                 self.assoc.release()
-                return self.PrimitiveResult("AssocFailure", 0xD001, assoc_result.description, None)
+                return self.PrimitiveResult("AssocFailure", 0xD001, details.description, None)
 
         result = self._send_upswatchnaction_request(
             assoc=self.assoc,
@@ -245,7 +241,9 @@ def subscribe_globally(self, lock: bool = False, matching_keys=None):
             self.logger.debug(matching_keys)
         else:
             self.logger.debug(f"(Unfiltered) Global Subscription {lock_str}")
-        return self._toggle_subscription(action_type_id=ACTION_TYPE_ID_SUBSCRIBE, lock=lock, matching_keys=matching_keys)
+        return self._toggle_subscription(
+            action_type_id=UPSActionTypeID.SUBSCRIBE.value, lock=lock, matching_keys=matching_keys
+        )
 
     def unsubscribe_globally(self):
         """Unsubscribe from receiving UPS Event Reports for all existing
@@ -258,7 +256,7 @@ def unsubscribe_globally(self):
             indicating the outcome of the request.
         """
         self.logger.debug("Global Unsubscription")
-        return self._toggle_subscription(action_type_id=ACTION_TYPE_ID_UNSUBSCRIBE)
+        return self._toggle_subscription(action_type_id=UPSActionTypeID.UNSUBSCRIBE.value)
 
     def suspend_global_subscription(self):
         """Unsubscribe from receiving UPS Event Reports for all new UPS
@@ -271,7 +269,7 @@ def suspend_global_subscription(self):
             indicating the outcome of the request.
         """
         self.logger.debug("Suspend Global Subscription")
-        return self._toggle_subscription(action_type_id=ACTION_TYPE_ID_SUSPEND)
+        return self._toggle_subscription(action_type_id=UPSActionTypeID.SUSPEND.value)
 
     def subscribe(self, instance_uid: UID, lock: bool = False):
         """Subscribe to receive UPS Event Reports for a specific
@@ -292,7 +290,7 @@ def subscribe(self, instance_uid: UID, lock: bool = False):
             indicating the outcome of the request.
         """
         self.logger.debug("Single UPS Subscription")
-        return self._toggle_subscription(action_type_id=ACTION_TYPE_ID_SUBSCRIBE, instance_uid=instance_uid, lock=lock)
+        return self._toggle_subscription(action_type_id=UPSActionTypeID.SUBSCRIBE.value, instance_uid=instance_uid, lock=lock)
 
     def unsubscribe(self, instance_uid: UID):
         """Unsubscribe from receiving UPS Event Reports for a specific
@@ -310,7 +308,7 @@ def unsubscribe(self, instance_uid: UID):
             indicating the outcome of the request.
         """
         self.logger.debug("Single UPS Unsubscription")
-        return self._toggle_subscription(action_type_id=ACTION_TYPE_ID_UNSUBSCRIBE, instance_uid=instance_uid)
+        return self._toggle_subscription(action_type_id=UPSActionTypeID.UNSUBSCRIBE.value, instance_uid=instance_uid)
 
     def _get_status_description(self, status_code):
         return UNIFIED_PROCEDURE_STEP_SERVICE_CLASS_STATUS.get(status_code, ("Unknown", "Unknown status code"))[1]


Reviews:
user: login: sourcery-ai[bot]
body: Hey @dwikler - I've reviewed your changes - here's some feedback:

- Consider simplifying the association logic in `BaseSCU` regarding accepted/rejected presentation contexts.
- The dual mechanism for handling `transaction_uid` (internal storage vs. explicit argument) in `UPSPullNActionSCU` could potentially be simplified.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟢 **General issues**: all looks good
- 🟢 **Security**: all looks good
- 🟡 **Testing**: 5 issues found
- 🟡 **Complexity**: 1 issue found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T12:22:57Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:32:01Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:32:13Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:32:36Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T12:32:47Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:32:52Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:37:34Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T12:37:44Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:40:18Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:41:50Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:42:02Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T12:42:02Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T12:42:11Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T12:42:22Z
user: login: sourcery-ai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T12:42:32Z
user: login: sjswerdloff
body: I'd like to have a bit of discussion around some of my comments (even if you don't agree with any of them).

Also, With pytest you can have custom marks
e.g.:
@pytest.mark.integration
@pytest.mark.slow

So that you can separate out what gets tested on the command line.
One can also put integration tests in a separate directory from unit tests (and then choose which directories get listed for testing).  I'm not sure if unittest has the same kind of decorators available, but the separate directories should work for almost any test framework.

In the short term, I don't think it's a big deal.
In the longer term, if we want to test the SCUs against 3rd party SCPs (that might involve a fair amount of compute/time, e.g. a dockerized version of dcm4chee ) "prior to release" (but not on every PR), it might be beneficial.
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-05-04T02:52:04Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T11:08:37Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T11:22:13Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T11:22:38Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T14:29:47Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T14:36:11Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T14:48:18Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-04T14:56:36Z
user: login: sjswerdloff
body: LGTM.
If you don't want to change the variable name with the double underscore, that's fine.
I think we should spend some time providing typing throughout (split it ~ 50/50 between us?) and use pyright or something like it to help us stay on top of that.
I'm very happy that you updated the _association() return values with boolean, NamedTuple.  It did however highlight the value of extensive typing (at least for code review). 
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-05T02:30:01Z
user: login: dwikler
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-10T16:30:41Z

Review comments:
diff_hunk: @@ -109,7 +109,7 @@ def test_associate_rejection(self):
     def test_associate_ae_params_not_set(self, called_ip, called_port):
         base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
 
-        assoc_result = base_scu._associate()
+        assoc_result = base_scu._associate(verbose=True)
         self.assertEqual(assoc_result.status, "Error")
         self.assertEqual(str(assoc_result.description), "Called AE parameters not set")
         self.test_logger.info(
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Add unit tests for the `_validate_sop_classes` method

Cover cases where all required SOP classes are accepted, some are missing, `required_sop_classes` is None (defaults to all requested contexts), and when it's an empty list.

Suggested implementation:

```python
    def test_validate_sop_classes_all_required_accepted(self, called_ip, called_port):
        base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
        # Test when all required SOP classes are accepted
        proposed = [
            MagicMock(abstract_syntax="1.2.840.10008.1.1"),
            MagicMock(abstract_syntax="1.2.840.10008.5.1.4.34.6.2")
        ]
        required = ["1.2.840.10008.1.1", "1.2.840.10008.5.1.4.34.6.2"]
        result = base_scu._validate_sop_classes(proposed, required)
        self.assertTrue(result)

    def test_validate_sop_classes_some_missing(self, called_ip, called_port):
        base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
        # Test when some required SOP classes are missing
        proposed = [
            MagicMock(abstract_syntax="1.2.840.10008.1.1")
        ]
        required = ["1.2.840.10008.1.1", "1.2.840.10008.5.1.4.34.6.2"]
        result = base_scu._validate_sop_classes(proposed, required)
        self.assertFalse(result)

    def test_validate_sop_classes_required_none(self, called_ip, called_port):
        base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
        # Test when required_sop_classes is None (defaults to all proposed contexts)
        proposed = [
            MagicMock(abstract_syntax="1.2.840.10008.1.1"),
            MagicMock(abstract_syntax="1.2.840.10008.5.1.4.34.6.2")
        ]
        result = base_scu._validate_sop_classes(proposed, None)
        self.assertTrue(result)

    def test_validate_sop_classes_empty_list(self, called_ip, called_port):
        base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
        # Test when required_sop_classes is an empty list (should be rejected)
        proposed = [
            MagicMock(abstract_syntax="1.2.840.10008.1.1"),
            MagicMock(abstract_syntax="1.2.840.10008.5.1.4.34.6.2")
        ]
        result = base_scu._validate_sop_classes(proposed, [])
        self.assertFalse(result)

```

Make sure that:
1. The BaseSCU class defines or inherits the _validate_sop_classes method.
2. The tests file correctly imports MagicMock from unittest.mock (or your mocking library) and any other dependencies.
3. The new tests match your codebase’s testing conventions and any required fixtures for passing called_ip and called_port.
created_at: 2025-05-03T12:22:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,7 +95,7 @@ def test_associate_rejection(self):
         with patch.object(self.base_scu.assoc, "is_established", new_callable=PropertyMock) as mock_is_established:
             # Test association rejection
             mock_is_established.return_value = False
-            assoc_result = self.base_scu._associate()
+            assoc_result = self.base_scu._associate(verbose=True)
             self.assertEqual(assoc_result.status, "Error")
         self.test_logger.info("Test _associate with rejection")
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Consider testing `_associate` with `verbose=False`

Add a test for verbose=False to confirm it returns True on success and False on failure, as callers like CStoreSCU expect.

Suggested implementation:

```python
    def test_associate_ae_params_not_set(self, called_ip, called_port):
        base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")

        assoc_result = base_scu._associate(verbose=True)
        self.assertEqual(assoc_result.status, "Error")
        self.assertEqual(str(assoc_result.description), "Called AE parameters not set")
        self.test_logger.info(
            (
                [MagicMock(abstract_syntax="1.2.840.10008.1.1"), MagicMock(abstract_syntax="1.2.840.10008.5.1.4.34.6.2")],
                ...
            )
        )

    # New tests for _associate with verbose=False
    def test_associate_verbose_false_success(self):
        # Simulate a successful association by having is_established return True
        with patch.object(self.base_scu.assoc, "is_established", new_callable=PropertyMock) as mock_is_established:
            mock_is_established.return_value = True
            assoc_result = self.base_scu._associate(verbose=False)
            # Expecting True when the association is successful
            self.assertTrue(assoc_result)
        self.test_logger.info("Test _associate verbose=False with success")

    def test_associate_verbose_false_failure(self):
        # Simulate a failed association by having is_established return False
        with patch.object(self.base_scu.assoc, "is_established", new_callable=PropertyMock) as mock_is_established:
            mock_is_established.return_value = False
            assoc_result = self.base_scu._associate(verbose=False)
            # Expecting False when the association fails
            self.assertFalse(assoc_result)
        self.test_logger.info("Test _associate verbose=False with failure")

```

These new test cases assume that when verbose=False, the _associate() method returns a Boolean value (True on a successful association, False otherwise). If the internal implementation of _associate or expected results differ, ensure the assertions are updated accordingly. Also, confirm that any necessary imports (e.g., patch, PropertyMock) are available in the file.
created_at: 2025-05-03T12:22:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import logging
+import os
+import shutil
+import signal
+import tempfile
+import unittest
+from logging.handlers import MemoryHandler
+from subprocess import TimeoutExpired
+
+import psutil
+
+from tdwii_plus_examples.tests.utils.generate_sop_instances import generate_ups
+from tdwii_plus_examples.tests.utils.utils import get_configured_logger, start_scp
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+from tdwii_plus_examples.upspushncreatescu import UPSPushNCreateSCU
+
+
+class TestUPSPullNActionSCUIntegration(unittest.TestCase):
path: tdwii_plus_examples/tests/test_upspullnactionscu_integration.py
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Add integration tests for failure scenarios and edge cases

Please add integration tests for these failure modes:
- Claiming/completing/canceling a non-existent SOP instance
- Claiming a UPS already IN PROGRESS or COMPLETED
- Completing/canceling with an invalid transaction_uid or when the UPS isn’t IN PROGRESS
- Association rejections (e.g., unsupported SOP class)
- N-ACTION failures (e.g., “Processing Failure”, “No Such SOP Instance”)

Suggested implementation:

```python
# Create a temporary directory for UPSSCP staging area

    def test_claiming_non_existent_sop_instance(self):
        """
        Integration test for claiming, completing, and canceling a non-existent SOP instance.
        Expected: The system should report that the SOP instance does not exist.
        """
        # TODO: Implement the simulation and assertion for a non-existent SOP instance.
        self.assertTrue(True)  # Placeholder; replace with actual test logic

    def test_claiming_ups_already_in_progress_or_completed(self):
        """
        Integration test for claiming a UPS that is already in progress or completed.
        Expected: The system should not allow claiming a UPS that is already active or finalized.
        """
        # TODO: Implement the simulation and assertion for UPS already in progress or completed.
        self.assertTrue(True)  # Placeholder; replace with actual test logic

    def test_completing_canceling_invalid_transaction_uid_or_inactive_ups(self):
        """
        Integration test for completing or canceling with an invalid transaction UID or when the UPS isn't in progress.
        Expected: The system should reject such completion or cancellation requests.
        """
        # TODO: Implement the simulation and assertion for invalid transaction_uid or inactive UPS.
        self.assertTrue(True)  # Placeholder; replace with actual test logic

    def test_association_rejections_for_unsupported_sop_class(self):
        """
        Integration test for association rejections with an unsupported SOP class.
        Expected: The system should reject the association attempt.
        """
        # TODO: Implement the simulation and assertion for association rejection.
        self.assertTrue(True)  # Placeholder; replace with actual test logic

    def test_n_action_failures_processing_failure_no_such_sop_instance(self):
        """
        Integration test for N-ACTION failures, for example "Processing Failure" and "No Such SOP Instance".
        Expected: The system should handle these failures correctly and report appropriate errors.
        """
        # TODO: Implement the simulation and assertion for N-ACTION failure scenarios.
        self.assertTrue(True)  # Placeholder; replace with actual test logic

```

You will need to replace the placeholder assertions (self.assertTrue(True)) with the actual test implementation. This includes setting up the UPSPullNActionSCU instance, simulating calls for the failure modes, and asserting the proper error handling or response from the system. Adjust the logging, exception handling, or any other details depending on your overall test framework and the specifics of the integration system.
created_at: 2025-05-03T12:22:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,165 @@
+import logging
+import unittest
+from unittest import mock
+
+from parameterized import parameterized
+from pydicom import Dataset
+from pydicom.uid import generate_uid
+
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+
+LOGGER = logging.getLogger(__name__)
+
+
+class TestUPSPullNActionSCU(unittest.TestCase):
path: tdwii_plus_examples/tests/test_upspullnactionscu_unit.py
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Expand unit tests to cover internal methods and logic

Please add granular tests for these internal methods:
- `_add_requested_context`: ensure it calls `super()._add_requested_context()` and adds `UnifiedProcedureStepPull`.
- `_get_status_description`: cover known UPS-specific status codes and unknown codes.
- `_close_ups`: test retrieving `transaction_uid` from `self.ups_transaction_info` when not provided and its removal on success.
- `_change_ups_state`: verify handling of 'Error', 'Warning', and 'Success' assoc_result values and the parameters passed to `assoc.send_n_action`.

Suggested implementation:

```python
import logging
import unittest
from parameterized import parameterized
from pydicom import Dataset
from pydicom.uid import generate_uid
from unittest.mock import MagicMock

from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU

LOGGER = logging.getLogger(__name__)


class TestUPSPullNActionSCU(unittest.TestCase):
    def setUp(self):
        """Setup before each test."""
        # Create UPSPullNActionSCU instance
        self.scu = UPSPullNActionSCU(
            calling_ae_title="CALLING_AE",
            called_ip="127.0.0.1",
            called_port=11112,
            called_ae_title="CALLED_AE",
            logger=LOGGER,
        )

    @parameterized.expand(
        # Existing parameterized tests
    )
    def test_something(self, param):
        # Existing test code
        pass

```

These tests assume that:
1. _add_requested_context returns a list of contexts that should include "UnifiedProcedureStepPull".
2. _get_status_description returns string descriptions for known UPS-specific status codes and a generic description for unknown codes.
3. _close_ups uses and then removes a 'transaction_uid' from self.ups_transaction_info.
4. _change_ups_state calls assoc.send_n_action accordingly based on the state passed.
Make sure to adjust the expected values and behaviors based on the actual implementation details of UPSPullNActionSCU.
created_at: 2025-05-03T12:22:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+import sys
+import unittest
+from unittest import mock
+
+import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu
+
+
+class TestUPSChangeStateSCUCLI(unittest.TestCase):
path: tdwii_plus_examples/tests/cli/test_upschangestatescu.py
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Consider asserting CLI output messages

Capture stdout/stderr (e.g., with unittest.mock.patch('sys.stdout', new_callable=io.StringIO)) and assert expected messages like 'Verification (C-ECHO) successful' or 'Change UPS State failed' are printed.

Suggested implementation:

```python
import sys
import io
import unittest
from unittest import mock

import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu

```

```python
        with mock.patch.object(sys, "argv", args), \
             mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
            try:
                upschangestcu_ret = upschangestatescu.main()
            except SystemExit:
                pass
            return mock_stdout.getvalue()

```

```python
        # Simulate a successful UPS state change
        self.mock_scu.return_value.perform_action.return_value = True
        output = self.run_main_with_args(["prog", "--simulate-success"])
        self.assertIn("Verification (C-ECHO) successful", output)

```

```python
        # Simulate a failed UPS state change
        self.mock_scu.return_value.perform_action.return_value = False
        output = self.run_main_with_args(["prog", "--simulate-failure"])
        self.assertIn("Change UPS State failed", output)

```

The new test methods use simulated command line arguments like "--simulate-success" and "--simulate-failure" along with return values from the mock UPSPullNActionSCU action. You may need to adjust the keys or simulate the behavior of upschangestatescu.main() depending on your actual application logic.
created_at: 2025-05-03T12:22:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -103,73 +111,126 @@ def __init__(
 
     def _add_requested_context(self):
         """
-        Adds the DICOM UPS Push SOP Class presentation context to the AE.
+        Adds the DICOM Verification SOP Class presentation context to the AE.
         Default transfer syntaxes are included.
 
-        This method may be overridden in derived classes if another SOP
-        Class needs to be negotiated.
+        Derived classes *must* override this method to add the presentation
+        contexts for the specific SOP Classes they support.
         """
         self.ae.add_requested_context(Verification)
         self.logger.debug(f"Verification Presentation context added: \n {self.ae.requested_contexts[0]}")
 
     # Create a named tuple to hold the result of the Association
     AssociationResult = namedtuple("AssociationResult", ["status", "description", "accepted_sop_classes"])
 
-    def _associate(self):
+    def _associate(self, required_sop_classes=None, verbose: bool = True):
         """
-        Establish an association with the remote AE and check contexts.
+        Establishes an association with the remote AE.
+
+        Returns an AssociationResult named tuple containing the status, accepted SOP classes, and a description.
+        The status is "Success" if all requested presentation contexts are accepted, "Warning" if only some are
+        accepted and "Error" if the association fails.
 
         Returns:
-            AssociationResult: A named tuple containing the status, accepted SOP classes, and description.
+            An AssociationResult namedtuple with the following fields:
+
+            - status (str): The status of the association ("Success", "Warning", or "Error").
+            - description (str): A description of the association result.
+            - accepted_sop_classes (list of UID): A list of accepted SOP Class UIDs.
         """
+
+        result = self._attempt_association(required_sop_classes)
+        return result if verbose else (result.status == "Success")
+
+    def _attempt_association(self, required_sop_classes=None):
         if (self.called_ip is None or self.called_ip == "") or self.called_port is None:
             return self.AssociationResult(status="Error", description="Called AE parameters not set", accepted_sop_classes=[])
-        else:
-            if self.called_ae_title is None:
-                self.called_ae_title = "ANYSCP"
-                self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
 
-            self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
-            if not self.assoc.is_established:
-                return self.AssociationResult(
-                    status="Error", description="Association could not be established", accepted_sop_classes=[]
-                )
-            self.logger.debug("Association established")
+        if self.called_ae_title is None:
+            self.called_ae_title = "ANYSCP"
+            self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
+
+        self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
+        if not self.assoc.is_established:
+            return self.AssociationResult(
+                status="Error", description="Association could not be established", accepted_sop_classes=[]
+            )
+
+        self.logger.debug("Association established")
 
-            # Initialize status of future requests
-            self.status = False
+        # Initialize status of future requests
+        self.status = False
 
-            # Check if all requested contexts were accepted
-            return self._all_contexts_accepted(self.assoc)
+        # Check if all SOP Classes of requested contexts were accepted
+        result = self._check_accepted_sop_classes(self.assoc)
+        if result.status:
+            return self.AssociationResult(
+                status="Success",
+                description="All requested SOP Classes were accepted",
+                accepted_sop_classes=[UID(uid) for uid in result.accepted_sop_classes],
+            )
+        else:
+            # Check if all required contexts were accepted
+            return self._validate_sop_classes(result, required_sop_classes)
 
-    def _all_contexts_accepted(self, assoc: Association) -> AssociationResult:
+    def _check_accepted_sop_classes(self, assoc: Association) -> AssociationResult:
path: tdwii_plus_examples/basescu.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider using a consistent string status type in helper methods to simplify layered conditionals.

```markdown
Consider using a consistent status type to simplify the layered conditionals. Currently, some helpers (e.g. `_check_accepted_sop_classes`) return a boolean status while others use strings like "Success", "Warning", or "Error". Standardizing the status throughout will make the control flow easier to follow and reduce the need for extra branching.

For example, refactor `_check_accepted_sop_classes` to always return a string status:

```python
def _check_accepted_sop_classes(self, assoc: Association) -> AssociationResult:
    requested = {ctx.abstract_syntax for ctx in self.ae.requested_contexts}
    accepted = {ctx.abstract_syntax for ctx in assoc.accepted_contexts}
    rejected = requested - accepted

    if rejected:
        accepted_sop = [UID(uid) for uid in accepted]
        self.logger.warning("The following SOP Classes were rejected: %s", ", ".join(str(uid) for uid in rejected))
        return self.AssociationResult(status="Warning", description="", accepted_sop_classes=accepted_sop)
    return self.AssociationResult(
        status="Success",
        description="All requested SOP Classes accepted",
        accepted_sop_classes=[UID(uid) for uid in accepted],
    )
```

Then update `_attempt_association` to check against these consistent string values:

```python
def _attempt_association(self, required_sop_classes=None):
    if not self.called_ip or self.called_port is None:
        return self.AssociationResult(status="Error", description="Called AE parameters not set", accepted_sop_classes=[])

    if not self.called_ae_title:
        self.called_ae_title = "ANYSCP"
        self.logger.warning("Using default Called Application Entity Title: %s", self.called_ae_title)

    self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
    if not self.assoc.is_established:
        return self.AssociationResult(status="Error", description="Association could not be established", accepted_sop_classes=[])

    self.logger.debug("Association established")
    self.status = False

    result = self._check_accepted_sop_classes(self.assoc)
    if result.status == "Success":
        return result
    return self._validate_sop_classes(result, required_sop_classes)
```

This change keeps functionality intact while simplifying inter-method contracts and reducing complexity.
```
created_at: 2025-05-03T12:22:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -109,7 +109,7 @@ def test_associate_rejection(self):
     def test_associate_ae_params_not_set(self, called_ip, called_port):
         base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
 
-        assoc_result = base_scu._associate()
+        assoc_result = base_scu._associate(verbose=True)
         self.assertEqual(assoc_result.status, "Error")
         self.assertEqual(str(assoc_result.description), "Called AE parameters not set")
         self.test_logger.info(
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: dwikler
body: I understand I can improve coverage but I would pass for now to keep moving forward and hopefully finish the refactoring.
created_at: 2025-05-03T12:32:01Z
author_association: COLLABORATOR
diff_hunk: @@ -95,7 +95,7 @@ def test_associate_rejection(self):
         with patch.object(self.base_scu.assoc, "is_established", new_callable=PropertyMock) as mock_is_established:
             # Test association rejection
             mock_is_established.return_value = False
-            assoc_result = self.base_scu._associate()
+            assoc_result = self.base_scu._associate(verbose=True)
             self.assertEqual(assoc_result.status, "Error")
         self.test_logger.info("Test _associate with rejection")
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: dwikler
body: I understand I can improve coverage but I would pass for now to keep moving forward and hopefully finish the refactoring.
created_at: 2025-05-03T12:32:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,171 @@
+import logging
+import os
+import shutil
+import signal
+import tempfile
+import unittest
+from logging.handlers import MemoryHandler
+from subprocess import TimeoutExpired
+
+import psutil
+
+from tdwii_plus_examples.tests.utils.generate_sop_instances import generate_ups
+from tdwii_plus_examples.tests.utils.utils import get_configured_logger, start_scp
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+from tdwii_plus_examples.upspushncreatescu import UPSPushNCreateSCU
+
+
+class TestUPSPullNActionSCUIntegration(unittest.TestCase):
path: tdwii_plus_examples/tests/test_upspullnactionscu_integration.py
user: login: dwikler
body: These are tested in unit test
created_at: 2025-05-03T12:32:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,171 @@
+import logging
+import os
+import shutil
+import signal
+import tempfile
+import unittest
+from logging.handlers import MemoryHandler
+from subprocess import TimeoutExpired
+
+import psutil
+
+from tdwii_plus_examples.tests.utils.generate_sop_instances import generate_ups
+from tdwii_plus_examples.tests.utils.utils import get_configured_logger, start_scp
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+from tdwii_plus_examples.upspushncreatescu import UPSPushNCreateSCU
+
+
+class TestUPSPullNActionSCUIntegration(unittest.TestCase):
path: tdwii_plus_examples/tests/test_upspullnactionscu_integration.py
user: login: sourcery-ai[bot]
body: I understand that you've suggested adding integration tests for various failure scenarios, and the author (dwikler) has responded that these scenarios are already tested in unit tests.

Given this context, I'll provide a constructive response:

While unit tests are valuable, integration tests serve a different purpose by verifying system behavior in a more holistic, end-to-end context. Integration tests can:
- Validate interactions between components
- Catch issues that might not be apparent in isolated unit tests
- Simulate real-world scenarios more comprehensively

I recommend:
1. Reviewing the existing unit tests to confirm full coverage
2. Consider adding a few key integration tests that validate critical failure paths
3. If the unit tests already comprehensively cover these scenarios, document this clearly in the test files or README

Would you be open to sharing the existing unit tests or discussing which specific failure scenarios are already covered? This would help ensure robust testing across the UPSPullNActionSCU implementation.
created_at: 2025-05-03T12:32:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+import sys
+import unittest
+from unittest import mock
+
+import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu
+
+
+class TestUPSChangeStateSCUCLI(unittest.TestCase):
path: tdwii_plus_examples/tests/cli/test_upschangestatescu.py
user: login: dwikler
body: I understand I can improve coverage but I would pass for now to keep moving forward and hopefully finish the refactoring.
created_at: 2025-05-03T12:32:52Z
author_association: COLLABORATOR
diff_hunk: @@ -103,73 +111,126 @@ def __init__(
 
     def _add_requested_context(self):
         """
-        Adds the DICOM UPS Push SOP Class presentation context to the AE.
+        Adds the DICOM Verification SOP Class presentation context to the AE.
         Default transfer syntaxes are included.
 
-        This method may be overridden in derived classes if another SOP
-        Class needs to be negotiated.
+        Derived classes *must* override this method to add the presentation
+        contexts for the specific SOP Classes they support.
         """
         self.ae.add_requested_context(Verification)
         self.logger.debug(f"Verification Presentation context added: \n {self.ae.requested_contexts[0]}")
 
     # Create a named tuple to hold the result of the Association
     AssociationResult = namedtuple("AssociationResult", ["status", "description", "accepted_sop_classes"])
 
-    def _associate(self):
+    def _associate(self, required_sop_classes=None, verbose: bool = True):
         """
-        Establish an association with the remote AE and check contexts.
+        Establishes an association with the remote AE.
+
+        Returns an AssociationResult named tuple containing the status, accepted SOP classes, and a description.
+        The status is "Success" if all requested presentation contexts are accepted, "Warning" if only some are
+        accepted and "Error" if the association fails.
 
         Returns:
-            AssociationResult: A named tuple containing the status, accepted SOP classes, and description.
+            An AssociationResult namedtuple with the following fields:
+
+            - status (str): The status of the association ("Success", "Warning", or "Error").
+            - description (str): A description of the association result.
+            - accepted_sop_classes (list of UID): A list of accepted SOP Class UIDs.
         """
+
+        result = self._attempt_association(required_sop_classes)
+        return result if verbose else (result.status == "Success")
+
+    def _attempt_association(self, required_sop_classes=None):
         if (self.called_ip is None or self.called_ip == "") or self.called_port is None:
             return self.AssociationResult(status="Error", description="Called AE parameters not set", accepted_sop_classes=[])
-        else:
-            if self.called_ae_title is None:
-                self.called_ae_title = "ANYSCP"
-                self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
 
-            self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
-            if not self.assoc.is_established:
-                return self.AssociationResult(
-                    status="Error", description="Association could not be established", accepted_sop_classes=[]
-                )
-            self.logger.debug("Association established")
+        if self.called_ae_title is None:
+            self.called_ae_title = "ANYSCP"
+            self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
+
+        self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
+        if not self.assoc.is_established:
+            return self.AssociationResult(
+                status="Error", description="Association could not be established", accepted_sop_classes=[]
+            )
+
+        self.logger.debug("Association established")
 
-            # Initialize status of future requests
-            self.status = False
+        # Initialize status of future requests
+        self.status = False
 
-            # Check if all requested contexts were accepted
-            return self._all_contexts_accepted(self.assoc)
+        # Check if all SOP Classes of requested contexts were accepted
+        result = self._check_accepted_sop_classes(self.assoc)
+        if result.status:
+            return self.AssociationResult(
+                status="Success",
+                description="All requested SOP Classes were accepted",
+                accepted_sop_classes=[UID(uid) for uid in result.accepted_sop_classes],
+            )
+        else:
+            # Check if all required contexts were accepted
+            return self._validate_sop_classes(result, required_sop_classes)
 
-    def _all_contexts_accepted(self, assoc: Association) -> AssociationResult:
+    def _check_accepted_sop_classes(self, assoc: Association) -> AssociationResult:
path: tdwii_plus_examples/basescu.py
user: login: dwikler
body: I want to keep both options to return simple boolean or detailed result information for now. We could create an improvement issue to provide this in all subclasses or to only keep boolean/int return values and propose another mechanism to retrieve the detailed result. The benefit of the detailed result vs log messages is mainly for GUI apps where meaningful messages could be displayed to user in case of errors.
created_at: 2025-05-03T12:37:33Z
author_association: COLLABORATOR
diff_hunk: @@ -103,73 +111,126 @@ def __init__(
 
     def _add_requested_context(self):
         """
-        Adds the DICOM UPS Push SOP Class presentation context to the AE.
+        Adds the DICOM Verification SOP Class presentation context to the AE.
         Default transfer syntaxes are included.
 
-        This method may be overridden in derived classes if another SOP
-        Class needs to be negotiated.
+        Derived classes *must* override this method to add the presentation
+        contexts for the specific SOP Classes they support.
         """
         self.ae.add_requested_context(Verification)
         self.logger.debug(f"Verification Presentation context added: \n {self.ae.requested_contexts[0]}")
 
     # Create a named tuple to hold the result of the Association
     AssociationResult = namedtuple("AssociationResult", ["status", "description", "accepted_sop_classes"])
 
-    def _associate(self):
+    def _associate(self, required_sop_classes=None, verbose: bool = True):
         """
-        Establish an association with the remote AE and check contexts.
+        Establishes an association with the remote AE.
+
+        Returns an AssociationResult named tuple containing the status, accepted SOP classes, and a description.
+        The status is "Success" if all requested presentation contexts are accepted, "Warning" if only some are
+        accepted and "Error" if the association fails.
 
         Returns:
-            AssociationResult: A named tuple containing the status, accepted SOP classes, and description.
+            An AssociationResult namedtuple with the following fields:
+
+            - status (str): The status of the association ("Success", "Warning", or "Error").
+            - description (str): A description of the association result.
+            - accepted_sop_classes (list of UID): A list of accepted SOP Class UIDs.
         """
+
+        result = self._attempt_association(required_sop_classes)
+        return result if verbose else (result.status == "Success")
+
+    def _attempt_association(self, required_sop_classes=None):
         if (self.called_ip is None or self.called_ip == "") or self.called_port is None:
             return self.AssociationResult(status="Error", description="Called AE parameters not set", accepted_sop_classes=[])
-        else:
-            if self.called_ae_title is None:
-                self.called_ae_title = "ANYSCP"
-                self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
 
-            self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
-            if not self.assoc.is_established:
-                return self.AssociationResult(
-                    status="Error", description="Association could not be established", accepted_sop_classes=[]
-                )
-            self.logger.debug("Association established")
+        if self.called_ae_title is None:
+            self.called_ae_title = "ANYSCP"
+            self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
+
+        self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
+        if not self.assoc.is_established:
+            return self.AssociationResult(
+                status="Error", description="Association could not be established", accepted_sop_classes=[]
+            )
+
+        self.logger.debug("Association established")
 
-            # Initialize status of future requests
-            self.status = False
+        # Initialize status of future requests
+        self.status = False
 
-            # Check if all requested contexts were accepted
-            return self._all_contexts_accepted(self.assoc)
+        # Check if all SOP Classes of requested contexts were accepted
+        result = self._check_accepted_sop_classes(self.assoc)
+        if result.status:
+            return self.AssociationResult(
+                status="Success",
+                description="All requested SOP Classes were accepted",
+                accepted_sop_classes=[UID(uid) for uid in result.accepted_sop_classes],
+            )
+        else:
+            # Check if all required contexts were accepted
+            return self._validate_sop_classes(result, required_sop_classes)
 
-    def _all_contexts_accepted(self, assoc: Association) -> AssociationResult:
+    def _check_accepted_sop_classes(self, assoc: Association) -> AssociationResult:
path: tdwii_plus_examples/basescu.py
user: login: sourcery-ai[bot]
body: I have created an issue for your comment: https://github.com/sjswerdloff/tdwii_plus_examples/issues/127
created_at: 2025-05-03T12:37:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -103,73 +111,126 @@ def __init__(
 
     def _add_requested_context(self):
         """
-        Adds the DICOM UPS Push SOP Class presentation context to the AE.
+        Adds the DICOM Verification SOP Class presentation context to the AE.
         Default transfer syntaxes are included.
 
-        This method may be overridden in derived classes if another SOP
-        Class needs to be negotiated.
+        Derived classes *must* override this method to add the presentation
+        contexts for the specific SOP Classes they support.
         """
         self.ae.add_requested_context(Verification)
         self.logger.debug(f"Verification Presentation context added: \n {self.ae.requested_contexts[0]}")
 
     # Create a named tuple to hold the result of the Association
     AssociationResult = namedtuple("AssociationResult", ["status", "description", "accepted_sop_classes"])
 
-    def _associate(self):
+    def _associate(self, required_sop_classes=None, verbose: bool = True):
         """
-        Establish an association with the remote AE and check contexts.
+        Establishes an association with the remote AE.
+
+        Returns an AssociationResult named tuple containing the status, accepted SOP classes, and a description.
+        The status is "Success" if all requested presentation contexts are accepted, "Warning" if only some are
+        accepted and "Error" if the association fails.
 
         Returns:
-            AssociationResult: A named tuple containing the status, accepted SOP classes, and description.
+            An AssociationResult namedtuple with the following fields:
+
+            - status (str): The status of the association ("Success", "Warning", or "Error").
+            - description (str): A description of the association result.
+            - accepted_sop_classes (list of UID): A list of accepted SOP Class UIDs.
         """
+
+        result = self._attempt_association(required_sop_classes)
+        return result if verbose else (result.status == "Success")
+
+    def _attempt_association(self, required_sop_classes=None):
         if (self.called_ip is None or self.called_ip == "") or self.called_port is None:
             return self.AssociationResult(status="Error", description="Called AE parameters not set", accepted_sop_classes=[])
-        else:
-            if self.called_ae_title is None:
-                self.called_ae_title = "ANYSCP"
-                self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
 
-            self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
-            if not self.assoc.is_established:
-                return self.AssociationResult(
-                    status="Error", description="Association could not be established", accepted_sop_classes=[]
-                )
-            self.logger.debug("Association established")
+        if self.called_ae_title is None:
+            self.called_ae_title = "ANYSCP"
+            self.logger.warning(f"Using default Called Application Entity Title: {self.called_ae_title}")
+
+        self.assoc = self.ae.associate(self.called_ip, self.called_port, ae_title=self.called_ae_title)
+        if not self.assoc.is_established:
+            return self.AssociationResult(
+                status="Error", description="Association could not be established", accepted_sop_classes=[]
+            )
+
+        self.logger.debug("Association established")
 
-            # Initialize status of future requests
-            self.status = False
+        # Initialize status of future requests
+        self.status = False
 
-            # Check if all requested contexts were accepted
-            return self._all_contexts_accepted(self.assoc)
+        # Check if all SOP Classes of requested contexts were accepted
+        result = self._check_accepted_sop_classes(self.assoc)
+        if result.status:
+            return self.AssociationResult(
+                status="Success",
+                description="All requested SOP Classes were accepted",
+                accepted_sop_classes=[UID(uid) for uid in result.accepted_sop_classes],
+            )
+        else:
+            # Check if all required contexts were accepted
+            return self._validate_sop_classes(result, required_sop_classes)
 
-    def _all_contexts_accepted(self, assoc: Association) -> AssociationResult:
+    def _check_accepted_sop_classes(self, assoc: Association) -> AssociationResult:
path: tdwii_plus_examples/basescu.py
user: login: dwikler
body: @sourcery-ai issue
created_at: 2025-05-03T12:40:18Z
author_association: COLLABORATOR
diff_hunk: @@ -109,7 +109,7 @@ def test_associate_rejection(self):
     def test_associate_ae_params_not_set(self, called_ip, called_port):
         base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
 
-        assoc_result = base_scu._associate()
+        assoc_result = base_scu._associate(verbose=True)
         self.assertEqual(assoc_result.status, "Error")
         self.assertEqual(str(assoc_result.description), "Called AE parameters not set")
         self.test_logger.info(
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: dwikler
body: @sourcery-ai issue
created_at: 2025-05-03T12:41:50Z
author_association: COLLABORATOR
diff_hunk: @@ -95,7 +95,7 @@ def test_associate_rejection(self):
         with patch.object(self.base_scu.assoc, "is_established", new_callable=PropertyMock) as mock_is_established:
             # Test association rejection
             mock_is_established.return_value = False
-            assoc_result = self.base_scu._associate()
+            assoc_result = self.base_scu._associate(verbose=True)
             self.assertEqual(assoc_result.status, "Error")
         self.test_logger.info("Test _associate with rejection")
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: dwikler
body: @sourcery-ai issue
created_at: 2025-05-03T12:42:02Z
author_association: COLLABORATOR
diff_hunk: @@ -109,7 +109,7 @@ def test_associate_rejection(self):
     def test_associate_ae_params_not_set(self, called_ip, called_port):
         base_scu = BaseSCU(self.scu_logger, "MY_AE_TITLE", called_ip, called_port, "SCP_AE_TITLE")
 
-        assoc_result = base_scu._associate()
+        assoc_result = base_scu._associate(verbose=True)
         self.assertEqual(assoc_result.status, "Error")
         self.assertEqual(str(assoc_result.description), "Called AE parameters not set")
         self.test_logger.info(
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: sourcery-ai[bot]
body: I have created an issue for your comment: https://github.com/sjswerdloff/tdwii_plus_examples/issues/128
created_at: 2025-05-03T12:42:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,7 +95,7 @@ def test_associate_rejection(self):
         with patch.object(self.base_scu.assoc, "is_established", new_callable=PropertyMock) as mock_is_established:
             # Test association rejection
             mock_is_established.return_value = False
-            assoc_result = self.base_scu._associate()
+            assoc_result = self.base_scu._associate(verbose=True)
             self.assertEqual(assoc_result.status, "Error")
         self.test_logger.info("Test _associate with rejection")
path: tdwii_plus_examples/tests/test_basescu_unit.py
user: login: sourcery-ai[bot]
body: I have created an issue for your comment: https://github.com/sjswerdloff/tdwii_plus_examples/issues/129
created_at: 2025-05-03T12:42:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+import sys
+import unittest
+from unittest import mock
+
+import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu
+
+
+class TestUPSChangeStateSCUCLI(unittest.TestCase):
path: tdwii_plus_examples/tests/cli/test_upschangestatescu.py
user: login: dwikler
body: @sourcery-ai issue
created_at: 2025-05-03T12:42:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,67 @@
+import sys
+import unittest
+from unittest import mock
+
+import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu
+
+
+class TestUPSChangeStateSCUCLI(unittest.TestCase):
path: tdwii_plus_examples/tests/cli/test_upschangestatescu.py
user: login: sourcery-ai[bot]
body: I have created an issue for your comment: https://github.com/sjswerdloff/tdwii_plus_examples/issues/130
created_at: 2025-05-03T12:42:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,226 @@
+from pydicom import Dataset
+from pydicom.uid import UID, generate_uid
+from pynetdicom.sop_class import UnifiedProcedureStepPull, UnifiedProcedureStepPush
+from pynetdicom.status import UNIFIED_PROCEDURE_STEP_SERVICE_CLASS_STATUS
+
+from tdwii_plus_examples.basescu import BaseSCU
+
+# Action Type ID constants
+ACTION_TYPE_ID_CHANGEUPSSTATE = 1
+
+# UPS Procedure Step State constants
path: tdwii_plus_examples/upspullnactionscu.py
user: login: sjswerdloff
body: could be an enum, but then you'd have to have IN_PROGRESS.value for the string.
This is fine, just thinking about an enum that could be shared across the entire package.
created_at: 2025-05-04T02:14:27Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,128 @@
+#!/usr/bin/env python
+"""
+A DICOM UPS Pull Service Class User (SCU) application.
+
+This application implements a UPS Pull N-ACTION - Change UPS State SOP Class SCU.
+It uses the DIMSE N-ACTION Service to change the state of UPS instances on a remote SCP.
+
+Usage:
+    upschangestatescu [options] ip port ups_uid state [transaction_uid]
+
+Arguments:
+    ip              IP address of called AE
+    port            TCP port number of called AE
+    ups_uid         SOP Instance UID of the UPS instance
+    state           Requested procedure step state ("IN PROGRESS", "CANCELED", or "COMPLETED")
+    transaction_uid Transaction UID of the UPS (required if state is "CANCELED" or "COMPLETED")
+
+Options:
+    -h, --help               Show this help message and exit
+    -aet, --ae_title         Application Entity Title (default: UPSPULLSCU)
+    -aec, --called_ae_title  Called Application Entity Title (default: ANYSCP)
+    -e, --echo               Verification of DICOM connection with Called AET
+    -v, --verbose            Set log level to INFO
+    -d, --debug              Set log level to DEBUG
+    -q, --quiet              Suppress all log output
+"""
+
+import argparse
+import logging
+
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Send a DICOM UPS Push N-CREATE request")
+    parser.add_argument("ip", type=str, help="IP address or hotname of called AE")
+    parser.add_argument("port", type=int, help="TCP port number of called AE")
+    parser.add_argument("ups_uid", help="SOP Instance UID of the UPS", type=str)
+    parser.add_argument(
+        "state",
+        nargs="?",
+        choices=["IN PROGRESS", "CANCELED", "COMPLETED"],
+        default="IN PROGRESS",
+        help='Requested procedure step state (default: "IN PROGRESS")',
+    )
+    parser.add_argument(
+        "transaction_uid",
+        nargs="?",
+        type=str,
+        default=None,
+        help="Transaction UID of the UPS (required for CANCELED or COMPLETED)",
+    )
+
+    parser.add_argument("-aet", "--ae_title", type=str, default="UPSPULLSCU", help="Application Entity Title")
+    parser.add_argument("-aec", "--called_ae_title", type=str, default="ANYSCP", help="Called Application Entity Title")
+    parser.add_argument("-e", "--echo", action="store_true", help="Verification of DICOM connection with Called AET")
+    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")
+    parser.add_argument("-q", "--quiet", action="store_true", help="Suppress all log output")
+
+    args = parser.parse_args()
+
+    # Argument consistency checks
+    if not args.echo and not args.ups_uid:
+        parser.error("Either -e/--echo or a ups_uid must be provided")
+    if args.echo and args.ups_uid:
+        print("Warning: ups_uid argument is ignored when using -e/--echo option")
+    if args.state == "IN PROGRESS" and args.transaction_uid:
+        parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+    if args.state in ("CANCELED", "COMPLETED") and not args.transaction_uid:
+        parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+
+    if args.quiet:
+        log_level = logging.CRITICAL
+    elif args.verbose:
+        log_level = logging.INFO
+    elif args.debug:
+        log_level = logging.DEBUG
+    else:
+        log_level = logging.WARNING
+
+    logging.basicConfig(level=log_level)
+    logger = logging.getLogger("upspullnactionscu")
+    logger.setLevel(log_level)
+
+    logger.info(f"Initializing UPSPullNActionSCU instance with remote SCP {args.called_ae_title}@{args.ip}:{args.port}")
+    scu = UPSPullNActionSCU(
+        logger=logger,
+        calling_ae_title=args.ae_title,
+        called_ip=args.ip,
+        called_port=args.port,
+        called_ae_title=args.called_ae_title,
+    )
+
+    # Verification requested
+    if args.echo:
+        print("Verification (C-ECHO) request")
+        result = scu.verify()
+        if result.status_category == "Success":
+            print("Verification (C-ECHO) successful")
+        else:
+            print(f"Verification (C-ECHO) failed: {result.status_description}")
+
+    # UPS instances state change requested
+    elif args.ups_uid is not None:
+        ups_instance_uid = args.ups_uid
+        if args.state == "IN PROGRESS":
+            if args.transaction_uid:
+                parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+            result = scu.claim_ups(ups_instance_uid)
path: tdwii_plus_examples/cli/upschangestatescu.py
user: login: sjswerdloff
body: how is the user of the CLI supposed to get the transaction UID?
It might be getting logged or printed somewhere underneath, but I think it would be useful for it to be printed out at this level for a successful claim_ups().
created_at: 2025-05-04T02:24:01Z
author_association: OWNER
diff_hunk: @@ -117,31 +117,35 @@ def _start_procedure(self):
         self.clearing_flag = False
         my_ae_title = self.ui.tdd_ae_line_edit.text()
         tms_ae_title = self.ui.ups_ae_line_edit.text()
-        naction_scu = NActionSCU(calling_ae_title=my_ae_title, called_ae_title=tms_ae_title)
+        ip_addr = tdwii_config.known_ae_ipaddr[tms_ae_title]
+        port = tdwii_config.known_ae_port[tms_ae_title]
+        naction_scu = UPSPullNActionSCU(
+            calling_ae_title=my_ae_title, called_ae_title=tms_ae_title, called_ip=ip_addr, called_port=port
+        )
         ups_uid = self._get_currently_selected_ups_uid()
         if self.current_transaction_uid is not None:
             logging.warning(
                 "There is a Transaction UID in use, starting multiple procedures in parallel \
                     with the TMS is not typical behavior"
             )
-        naction_scu.send_procedure_step_state_change(IN_PROGRESS, ups_uid=ups_uid)
-        self.current_transaction_uid = naction_scu.current_transaction_uid()
+        self.current_transaction_uid = naction_scu.claim_ups(sop_instance_uid=ups_uid)
path: tdwii_plus_examples/tdd/tdd_widget.py
user: login: sjswerdloff
body: This is what is "missing" from the CLI...
created_at: 2025-05-04T02:25:15Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,128 @@
+#!/usr/bin/env python
+"""
+A DICOM UPS Pull Service Class User (SCU) application.
+
+This application implements a UPS Pull N-ACTION - Change UPS State SOP Class SCU.
+It uses the DIMSE N-ACTION Service to change the state of UPS instances on a remote SCP.
+
+Usage:
+    upschangestatescu [options] ip port ups_uid state [transaction_uid]
+
+Arguments:
+    ip              IP address of called AE
+    port            TCP port number of called AE
+    ups_uid         SOP Instance UID of the UPS instance
+    state           Requested procedure step state ("IN PROGRESS", "CANCELED", or "COMPLETED")
+    transaction_uid Transaction UID of the UPS (required if state is "CANCELED" or "COMPLETED")
+
+Options:
+    -h, --help               Show this help message and exit
+    -aet, --ae_title         Application Entity Title (default: UPSPULLSCU)
+    -aec, --called_ae_title  Called Application Entity Title (default: ANYSCP)
+    -e, --echo               Verification of DICOM connection with Called AET
+    -v, --verbose            Set log level to INFO
+    -d, --debug              Set log level to DEBUG
+    -q, --quiet              Suppress all log output
+"""
+
+import argparse
+import logging
+
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Send a DICOM UPS Push N-CREATE request")
+    parser.add_argument("ip", type=str, help="IP address or hotname of called AE")
+    parser.add_argument("port", type=int, help="TCP port number of called AE")
+    parser.add_argument("ups_uid", help="SOP Instance UID of the UPS", type=str)
+    parser.add_argument(
+        "state",
+        nargs="?",
+        choices=["IN PROGRESS", "CANCELED", "COMPLETED"],
+        default="IN PROGRESS",
+        help='Requested procedure step state (default: "IN PROGRESS")',
+    )
+    parser.add_argument(
+        "transaction_uid",
+        nargs="?",
+        type=str,
+        default=None,
+        help="Transaction UID of the UPS (required for CANCELED or COMPLETED)",
+    )
+
+    parser.add_argument("-aet", "--ae_title", type=str, default="UPSPULLSCU", help="Application Entity Title")
+    parser.add_argument("-aec", "--called_ae_title", type=str, default="ANYSCP", help="Called Application Entity Title")
+    parser.add_argument("-e", "--echo", action="store_true", help="Verification of DICOM connection with Called AET")
+    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")
+    parser.add_argument("-q", "--quiet", action="store_true", help="Suppress all log output")
+
+    args = parser.parse_args()
+
+    # Argument consistency checks
+    if not args.echo and not args.ups_uid:
+        parser.error("Either -e/--echo or a ups_uid must be provided")
+    if args.echo and args.ups_uid:
+        print("Warning: ups_uid argument is ignored when using -e/--echo option")
+    if args.state == "IN PROGRESS" and args.transaction_uid:
+        parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+    if args.state in ("CANCELED", "COMPLETED") and not args.transaction_uid:
+        parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+
+    if args.quiet:
+        log_level = logging.CRITICAL
+    elif args.verbose:
+        log_level = logging.INFO
+    elif args.debug:
+        log_level = logging.DEBUG
+    else:
+        log_level = logging.WARNING
+
+    logging.basicConfig(level=log_level)
+    logger = logging.getLogger("upspullnactionscu")
+    logger.setLevel(log_level)
+
+    logger.info(f"Initializing UPSPullNActionSCU instance with remote SCP {args.called_ae_title}@{args.ip}:{args.port}")
+    scu = UPSPullNActionSCU(
+        logger=logger,
+        calling_ae_title=args.ae_title,
+        called_ip=args.ip,
+        called_port=args.port,
+        called_ae_title=args.called_ae_title,
+    )
+
+    # Verification requested
+    if args.echo:
+        print("Verification (C-ECHO) request")
+        result = scu.verify()
+        if result.status_category == "Success":
+            print("Verification (C-ECHO) successful")
+        else:
+            print(f"Verification (C-ECHO) failed: {result.status_description}")
+
+    # UPS instances state change requested
+    elif args.ups_uid is not None:
+        ups_instance_uid = args.ups_uid
+        if args.state == "IN PROGRESS":
+            if args.transaction_uid:
+                parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+            result = scu.claim_ups(ups_instance_uid)
+        elif args.state in ("CANCELED", "COMPLETED"):
+            if not args.transaction_uid:
+                parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+            if args.state == "CANCELED":
+                result = scu.cancel_ups(ups_instance_uid, args.transaction_uid)
+            else:
+                result = scu.complete_ups(ups_instance_uid, args.transaction_uid)
+        else:
+            parser.error(f"Unknown state: {args.state}")
+
+        if result:
+            print(f"Change UPS State to {args.state} successful")
+        else:
+            print("Change UPS State failed")
+
+
+if __name__ == "__main__":
+    main()
path: tdwii_plus_examples/cli/upschangestatescu.py
user: login: sjswerdloff
body: this always exits with exit code 0?
Shouldn't it exit with something non-zero if there is a failure or if the command line arguments aren't valid?
created_at: 2025-05-04T02:29:56Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,67 @@
+import sys
+import unittest
+from unittest import mock
+
+import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu
+
+
+class TestUPSChangeStateSCUCLI(unittest.TestCase):
+    def setUp(self):
+        self.scu_patch = mock.patch("tdwii_plus_examples.cli.upschangestatescu.UPSPullNActionSCU")
+        self.mock_scu_class = self.scu_patch.start()
+        self.mock_scu = self.mock_scu_class.return_value
+
+    def tearDown(self):
+        self.scu_patch.stop()
+
+    def run_main_with_args(self, args):
+        with mock.patch.object(sys, "argv", args):
+            try:
+                upschangestatescu.main()
+            except SystemExit as e:
+                return e.code
+            return 0
+
+    def test_claim_ups_success(self):
+        self.mock_scu.claim_ups.return_value = "1.2.3.4"
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "IN PROGRESS"])
+        self.mock_scu.claim_ups.assert_called_once_with("1.2.3.4.5")
+        self.assertEqual(exit_code, 0)
+
+    def test_cancel_ups_success(self):
+        self.mock_scu.cancel_ups.return_value = True
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "CANCELED", "2.3.4.5.6"])
+        self.mock_scu.cancel_ups.assert_called_once_with("1.2.3.4.5", "2.3.4.5.6")
+        self.assertEqual(exit_code, 0)
+
+    def test_complete_ups_success(self):
+        self.mock_scu.complete_ups.return_value = True
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "COMPLETED", "2.3.4.5.6"])
+        self.mock_scu.complete_ups.assert_called_once_with("1.2.3.4.5", "2.3.4.5.6")
+        self.assertEqual(exit_code, 0)
+
+    def test_missing_transaction_uid_for_cancel(self):
+        with mock.patch.object(sys, "argv", ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "CANCELED"]):
+            with self.assertRaises(SystemExit) as cm:
+                upschangestatescu.main()
+            self.assertNotEqual(cm.exception.code, 0)
+
+    def test_transaction_uid_given_for_in_progress(self):
+        with mock.patch.object(
+            sys, "argv", ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "IN PROGRESS", "2.3.4.5.6"]
+        ):
+            with self.assertRaises(SystemExit) as cm:
+                upschangestatescu.main()
+            self.assertNotEqual(cm.exception.code, 0)
+
+    def test_echo_success(self):
+        self.mock_scu.verify.return_value = mock.Mock(status_category="Success")
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "--echo"])
+        self.mock_scu.verify.assert_called_once()
+        self.assertEqual(exit_code, 0)
+
+    def test_echo_failure(self):
+        self.mock_scu.verify.return_value = mock.Mock(status_category="Failure", status_description="Failed")
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "--echo"])
+        self.mock_scu.verify.assert_called_once()
+        self.assertEqual(exit_code, 0)
path: tdwii_plus_examples/tests/cli/test_upschangestatescu.py
user: login: sjswerdloff
body: I'm a bit confused, because in run_main_with_args() you are providing for a non-zero exit code, but that doesn't seem like it will happen unless you have an uncaught exception.  If this is testing the library and you are looking at the return value then I think you need to assert that you got that return value.  If it's testing the CLI, I think you want the CLI to return a non-zero exit value if whatever the user was trying to do failed (including providing a bad command line).
created_at: 2025-05-04T02:35:38Z
author_association: OWNER
diff_hunk: @@ -30,7 +30,9 @@ def test_store_instances(self, mock_associate, mock_handle_response):
         """Test storing multiple DICOM instances."""
         # Create 2 empty instances
         ds_1 = Dataset()
+        ds_1.SOPClassUID = UID("1.2.840.10008.5.1.4.1.1.2")  # CT Image Storage SOP Class UID
path: tdwii_plus_examples/tests/test_cstorescu_unit.py
user: login: sjswerdloff
body: I think you could either extract to constants, or use the constants provided in pynetdicom:
https://pydicom.github.io/pynetdicom/stable/reference/sop_classes.html#module-pynetdicom.sop_class
created_at: 2025-05-04T02:38:57Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,171 @@
+import logging
+import os
+import shutil
+import signal
+import tempfile
+import unittest
+from logging.handlers import MemoryHandler
+from subprocess import TimeoutExpired
+
+import psutil
+
+from tdwii_plus_examples.tests.utils.generate_sop_instances import generate_ups
+from tdwii_plus_examples.tests.utils.utils import get_configured_logger, start_scp
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+from tdwii_plus_examples.upspushncreatescu import UPSPushNCreateSCU
+
+
+class TestUPSPullNActionSCUIntegration(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        # Set up the logger for the UPSPullNActionSCU to DEBUG level
+        # with a memory handler to store up to 100 log messages
+        cls.scu_logger, cls.memory_handler = get_configured_logger(
+            "upspullnactionscu", level=logging.DEBUG, handler_class=MemoryHandler, capacity=100
+        )
+
+        # Set up the logger for this test to DEBUG level
+        cls.test_logger, cls.stream_handler = get_configured_logger("test_upspullnactionscu", level=logging.DEBUG)
+
+        # Create a temporary directory for UPSSCP staging area
+        cls.temp_dir = tempfile.mkdtemp()
+        cls.test_logger.info(f"{cls.temp_dir} directory created")
+
+        # Start the UPS SCP Server (replace with your actual SCP command)
+        command = [
+            "./tdwii_plus_examples/cli/upsscp/upsscp.py",
+            "-v",
+            "--database-location",
+            f"{cls.temp_dir}/test.sqlite",
+            "--instance-location",
+            cls.temp_dir,
+        ]
+
+        cls.upsscp_started, cls.process, cls.stdout_thread = start_scp(command, cls.test_logger)
+        cls.test_logger.info(f"Started process with PID: {cls.process.pid}")
+
+        # Create the UPSPullNActionSCU instance
+        cls.scu = UPSPullNActionSCU(
+            calling_ae_title="UPSPULLSCU",
+            called_ae_title="ANYSCP",
+            called_ip="localhost",
+            called_port=11114,
+            logger=cls.scu_logger,
+        )
+
+    @classmethod
+    def tearDownClass(cls):
+        # Remove the temporary directory and its contents
+        files_in_temp_dir = os.listdir(cls.temp_dir)
+        shutil.rmtree(cls.temp_dir)
+        cls.test_logger.info(f"Removed the temp directory: {cls.temp_dir} and its contents: {files_in_temp_dir}")
+
+        # Try to gracefully terminate the process.
path: tdwii_plus_examples/tests/test_upspullnactionscu_integration.py
user: login: sjswerdloff
body: wouldn't it be better to terminate the process first and then clean out the temporary directory and it's contents?
Unless the process (SCP?) is not using the temporary directory or it's contents.
created_at: 2025-05-04T02:42:37Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,226 @@
+from pydicom import Dataset
+from pydicom.uid import UID, generate_uid
+from pynetdicom.sop_class import UnifiedProcedureStepPull, UnifiedProcedureStepPush
+from pynetdicom.status import UNIFIED_PROCEDURE_STEP_SERVICE_CLASS_STATUS
+
+from tdwii_plus_examples.basescu import BaseSCU
+
+# Action Type ID constants
+ACTION_TYPE_ID_CHANGEUPSSTATE = 1
+
+# UPS Procedure Step State constants
path: tdwii_plus_examples/upspullnactionscu.py
user: login: dwikler
body: Great idea, more elegant, I implemented it.
created_at: 2025-05-04T11:08:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,128 @@
+#!/usr/bin/env python
+"""
+A DICOM UPS Pull Service Class User (SCU) application.
+
+This application implements a UPS Pull N-ACTION - Change UPS State SOP Class SCU.
+It uses the DIMSE N-ACTION Service to change the state of UPS instances on a remote SCP.
+
+Usage:
+    upschangestatescu [options] ip port ups_uid state [transaction_uid]
+
+Arguments:
+    ip              IP address of called AE
+    port            TCP port number of called AE
+    ups_uid         SOP Instance UID of the UPS instance
+    state           Requested procedure step state ("IN PROGRESS", "CANCELED", or "COMPLETED")
+    transaction_uid Transaction UID of the UPS (required if state is "CANCELED" or "COMPLETED")
+
+Options:
+    -h, --help               Show this help message and exit
+    -aet, --ae_title         Application Entity Title (default: UPSPULLSCU)
+    -aec, --called_ae_title  Called Application Entity Title (default: ANYSCP)
+    -e, --echo               Verification of DICOM connection with Called AET
+    -v, --verbose            Set log level to INFO
+    -d, --debug              Set log level to DEBUG
+    -q, --quiet              Suppress all log output
+"""
+
+import argparse
+import logging
+
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Send a DICOM UPS Push N-CREATE request")
+    parser.add_argument("ip", type=str, help="IP address or hotname of called AE")
+    parser.add_argument("port", type=int, help="TCP port number of called AE")
+    parser.add_argument("ups_uid", help="SOP Instance UID of the UPS", type=str)
+    parser.add_argument(
+        "state",
+        nargs="?",
+        choices=["IN PROGRESS", "CANCELED", "COMPLETED"],
+        default="IN PROGRESS",
+        help='Requested procedure step state (default: "IN PROGRESS")',
+    )
+    parser.add_argument(
+        "transaction_uid",
+        nargs="?",
+        type=str,
+        default=None,
+        help="Transaction UID of the UPS (required for CANCELED or COMPLETED)",
+    )
+
+    parser.add_argument("-aet", "--ae_title", type=str, default="UPSPULLSCU", help="Application Entity Title")
+    parser.add_argument("-aec", "--called_ae_title", type=str, default="ANYSCP", help="Called Application Entity Title")
+    parser.add_argument("-e", "--echo", action="store_true", help="Verification of DICOM connection with Called AET")
+    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")
+    parser.add_argument("-q", "--quiet", action="store_true", help="Suppress all log output")
+
+    args = parser.parse_args()
+
+    # Argument consistency checks
+    if not args.echo and not args.ups_uid:
+        parser.error("Either -e/--echo or a ups_uid must be provided")
+    if args.echo and args.ups_uid:
+        print("Warning: ups_uid argument is ignored when using -e/--echo option")
+    if args.state == "IN PROGRESS" and args.transaction_uid:
+        parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+    if args.state in ("CANCELED", "COMPLETED") and not args.transaction_uid:
+        parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+
+    if args.quiet:
+        log_level = logging.CRITICAL
+    elif args.verbose:
+        log_level = logging.INFO
+    elif args.debug:
+        log_level = logging.DEBUG
+    else:
+        log_level = logging.WARNING
+
+    logging.basicConfig(level=log_level)
+    logger = logging.getLogger("upspullnactionscu")
+    logger.setLevel(log_level)
+
+    logger.info(f"Initializing UPSPullNActionSCU instance with remote SCP {args.called_ae_title}@{args.ip}:{args.port}")
+    scu = UPSPullNActionSCU(
+        logger=logger,
+        calling_ae_title=args.ae_title,
+        called_ip=args.ip,
+        called_port=args.port,
+        called_ae_title=args.called_ae_title,
+    )
+
+    # Verification requested
+    if args.echo:
+        print("Verification (C-ECHO) request")
+        result = scu.verify()
+        if result.status_category == "Success":
+            print("Verification (C-ECHO) successful")
+        else:
+            print(f"Verification (C-ECHO) failed: {result.status_description}")
+
+    # UPS instances state change requested
+    elif args.ups_uid is not None:
+        ups_instance_uid = args.ups_uid
+        if args.state == "IN PROGRESS":
+            if args.transaction_uid:
+                parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+            result = scu.claim_ups(ups_instance_uid)
path: tdwii_plus_examples/cli/upschangestatescu.py
user: login: dwikler
body: Definitely needed. I have added it.
created_at: 2025-05-04T11:22:13Z
author_association: COLLABORATOR
diff_hunk: @@ -117,31 +117,35 @@ def _start_procedure(self):
         self.clearing_flag = False
         my_ae_title = self.ui.tdd_ae_line_edit.text()
         tms_ae_title = self.ui.ups_ae_line_edit.text()
-        naction_scu = NActionSCU(calling_ae_title=my_ae_title, called_ae_title=tms_ae_title)
+        ip_addr = tdwii_config.known_ae_ipaddr[tms_ae_title]
+        port = tdwii_config.known_ae_port[tms_ae_title]
+        naction_scu = UPSPullNActionSCU(
+            calling_ae_title=my_ae_title, called_ae_title=tms_ae_title, called_ip=ip_addr, called_port=port
+        )
         ups_uid = self._get_currently_selected_ups_uid()
         if self.current_transaction_uid is not None:
             logging.warning(
                 "There is a Transaction UID in use, starting multiple procedures in parallel \
                     with the TMS is not typical behavior"
             )
-        naction_scu.send_procedure_step_state_change(IN_PROGRESS, ups_uid=ups_uid)
-        self.current_transaction_uid = naction_scu.current_transaction_uid()
+        self.current_transaction_uid = naction_scu.claim_ups(sop_instance_uid=ups_uid)
path: tdwii_plus_examples/tdd/tdd_widget.py
user: login: dwikler
body: added.
created_at: 2025-05-04T11:22:38Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,128 @@
+#!/usr/bin/env python
+"""
+A DICOM UPS Pull Service Class User (SCU) application.
+
+This application implements a UPS Pull N-ACTION - Change UPS State SOP Class SCU.
+It uses the DIMSE N-ACTION Service to change the state of UPS instances on a remote SCP.
+
+Usage:
+    upschangestatescu [options] ip port ups_uid state [transaction_uid]
+
+Arguments:
+    ip              IP address of called AE
+    port            TCP port number of called AE
+    ups_uid         SOP Instance UID of the UPS instance
+    state           Requested procedure step state ("IN PROGRESS", "CANCELED", or "COMPLETED")
+    transaction_uid Transaction UID of the UPS (required if state is "CANCELED" or "COMPLETED")
+
+Options:
+    -h, --help               Show this help message and exit
+    -aet, --ae_title         Application Entity Title (default: UPSPULLSCU)
+    -aec, --called_ae_title  Called Application Entity Title (default: ANYSCP)
+    -e, --echo               Verification of DICOM connection with Called AET
+    -v, --verbose            Set log level to INFO
+    -d, --debug              Set log level to DEBUG
+    -q, --quiet              Suppress all log output
+"""
+
+import argparse
+import logging
+
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Send a DICOM UPS Push N-CREATE request")
+    parser.add_argument("ip", type=str, help="IP address or hotname of called AE")
+    parser.add_argument("port", type=int, help="TCP port number of called AE")
+    parser.add_argument("ups_uid", help="SOP Instance UID of the UPS", type=str)
+    parser.add_argument(
+        "state",
+        nargs="?",
+        choices=["IN PROGRESS", "CANCELED", "COMPLETED"],
+        default="IN PROGRESS",
+        help='Requested procedure step state (default: "IN PROGRESS")',
+    )
+    parser.add_argument(
+        "transaction_uid",
+        nargs="?",
+        type=str,
+        default=None,
+        help="Transaction UID of the UPS (required for CANCELED or COMPLETED)",
+    )
+
+    parser.add_argument("-aet", "--ae_title", type=str, default="UPSPULLSCU", help="Application Entity Title")
+    parser.add_argument("-aec", "--called_ae_title", type=str, default="ANYSCP", help="Called Application Entity Title")
+    parser.add_argument("-e", "--echo", action="store_true", help="Verification of DICOM connection with Called AET")
+    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")
+    parser.add_argument("-q", "--quiet", action="store_true", help="Suppress all log output")
+
+    args = parser.parse_args()
+
+    # Argument consistency checks
+    if not args.echo and not args.ups_uid:
+        parser.error("Either -e/--echo or a ups_uid must be provided")
+    if args.echo and args.ups_uid:
+        print("Warning: ups_uid argument is ignored when using -e/--echo option")
+    if args.state == "IN PROGRESS" and args.transaction_uid:
+        parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+    if args.state in ("CANCELED", "COMPLETED") and not args.transaction_uid:
+        parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+
+    if args.quiet:
+        log_level = logging.CRITICAL
+    elif args.verbose:
+        log_level = logging.INFO
+    elif args.debug:
+        log_level = logging.DEBUG
+    else:
+        log_level = logging.WARNING
+
+    logging.basicConfig(level=log_level)
+    logger = logging.getLogger("upspullnactionscu")
+    logger.setLevel(log_level)
+
+    logger.info(f"Initializing UPSPullNActionSCU instance with remote SCP {args.called_ae_title}@{args.ip}:{args.port}")
+    scu = UPSPullNActionSCU(
+        logger=logger,
+        calling_ae_title=args.ae_title,
+        called_ip=args.ip,
+        called_port=args.port,
+        called_ae_title=args.called_ae_title,
+    )
+
+    # Verification requested
+    if args.echo:
+        print("Verification (C-ECHO) request")
+        result = scu.verify()
+        if result.status_category == "Success":
+            print("Verification (C-ECHO) successful")
+        else:
+            print(f"Verification (C-ECHO) failed: {result.status_description}")
+
+    # UPS instances state change requested
+    elif args.ups_uid is not None:
+        ups_instance_uid = args.ups_uid
+        if args.state == "IN PROGRESS":
+            if args.transaction_uid:
+                parser.error("transaction_uid should not be provided when state is IN PROGRESS")
+            result = scu.claim_ups(ups_instance_uid)
+        elif args.state in ("CANCELED", "COMPLETED"):
+            if not args.transaction_uid:
+                parser.error("transaction_uid argument is required when state is CANCELED or COMPLETED")
+            if args.state == "CANCELED":
+                result = scu.cancel_ups(ups_instance_uid, args.transaction_uid)
+            else:
+                result = scu.complete_ups(ups_instance_uid, args.transaction_uid)
+        else:
+            parser.error(f"Unknown state: {args.state}")
+
+        if result:
+            print(f"Change UPS State to {args.state} successful")
+        else:
+            print("Change UPS State failed")
+
+
+if __name__ == "__main__":
+    main()
path: tdwii_plus_examples/cli/upschangestatescu.py
user: login: dwikler
body: Added exit(1) in case of failure and adapted unit test to check the the right exit code value (0 for success, 1 for failure and 2 for parser error) 
created_at: 2025-05-04T14:29:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,67 @@
+import sys
+import unittest
+from unittest import mock
+
+import tdwii_plus_examples.cli.upschangestatescu as upschangestatescu
+
+
+class TestUPSChangeStateSCUCLI(unittest.TestCase):
+    def setUp(self):
+        self.scu_patch = mock.patch("tdwii_plus_examples.cli.upschangestatescu.UPSPullNActionSCU")
+        self.mock_scu_class = self.scu_patch.start()
+        self.mock_scu = self.mock_scu_class.return_value
+
+    def tearDown(self):
+        self.scu_patch.stop()
+
+    def run_main_with_args(self, args):
+        with mock.patch.object(sys, "argv", args):
+            try:
+                upschangestatescu.main()
+            except SystemExit as e:
+                return e.code
+            return 0
+
+    def test_claim_ups_success(self):
+        self.mock_scu.claim_ups.return_value = "1.2.3.4"
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "IN PROGRESS"])
+        self.mock_scu.claim_ups.assert_called_once_with("1.2.3.4.5")
+        self.assertEqual(exit_code, 0)
+
+    def test_cancel_ups_success(self):
+        self.mock_scu.cancel_ups.return_value = True
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "CANCELED", "2.3.4.5.6"])
+        self.mock_scu.cancel_ups.assert_called_once_with("1.2.3.4.5", "2.3.4.5.6")
+        self.assertEqual(exit_code, 0)
+
+    def test_complete_ups_success(self):
+        self.mock_scu.complete_ups.return_value = True
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "COMPLETED", "2.3.4.5.6"])
+        self.mock_scu.complete_ups.assert_called_once_with("1.2.3.4.5", "2.3.4.5.6")
+        self.assertEqual(exit_code, 0)
+
+    def test_missing_transaction_uid_for_cancel(self):
+        with mock.patch.object(sys, "argv", ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "CANCELED"]):
+            with self.assertRaises(SystemExit) as cm:
+                upschangestatescu.main()
+            self.assertNotEqual(cm.exception.code, 0)
+
+    def test_transaction_uid_given_for_in_progress(self):
+        with mock.patch.object(
+            sys, "argv", ["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "IN PROGRESS", "2.3.4.5.6"]
+        ):
+            with self.assertRaises(SystemExit) as cm:
+                upschangestatescu.main()
+            self.assertNotEqual(cm.exception.code, 0)
+
+    def test_echo_success(self):
+        self.mock_scu.verify.return_value = mock.Mock(status_category="Success")
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "--echo"])
+        self.mock_scu.verify.assert_called_once()
+        self.assertEqual(exit_code, 0)
+
+    def test_echo_failure(self):
+        self.mock_scu.verify.return_value = mock.Mock(status_category="Failure", status_description="Failed")
+        exit_code = self.run_main_with_args(["upschangestatescu", "127.0.0.1", "11114", "1.2.3.4.5", "--echo"])
+        self.mock_scu.verify.assert_called_once()
+        self.assertEqual(exit_code, 0)
path: tdwii_plus_examples/tests/cli/test_upschangestatescu.py
user: login: dwikler
body: solved. See previous comment.
created_at: 2025-05-04T14:36:11Z
author_association: COLLABORATOR
diff_hunk: @@ -30,7 +30,9 @@ def test_store_instances(self, mock_associate, mock_handle_response):
         """Test storing multiple DICOM instances."""
         # Create 2 empty instances
         ds_1 = Dataset()
+        ds_1.SOPClassUID = UID("1.2.840.10008.5.1.4.1.1.2")  # CT Image Storage SOP Class UID
path: tdwii_plus_examples/tests/test_cstorescu_unit.py
user: login: dwikler
body: done.
created_at: 2025-05-04T14:48:18Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,171 @@
+import logging
+import os
+import shutil
+import signal
+import tempfile
+import unittest
+from logging.handlers import MemoryHandler
+from subprocess import TimeoutExpired
+
+import psutil
+
+from tdwii_plus_examples.tests.utils.generate_sop_instances import generate_ups
+from tdwii_plus_examples.tests.utils.utils import get_configured_logger, start_scp
+from tdwii_plus_examples.upspullnactionscu import UPSPullNActionSCU
+from tdwii_plus_examples.upspushncreatescu import UPSPushNCreateSCU
+
+
+class TestUPSPullNActionSCUIntegration(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        # Set up the logger for the UPSPullNActionSCU to DEBUG level
+        # with a memory handler to store up to 100 log messages
+        cls.scu_logger, cls.memory_handler = get_configured_logger(
+            "upspullnactionscu", level=logging.DEBUG, handler_class=MemoryHandler, capacity=100
+        )
+
+        # Set up the logger for this test to DEBUG level
+        cls.test_logger, cls.stream_handler = get_configured_logger("test_upspullnactionscu", level=logging.DEBUG)
+
+        # Create a temporary directory for UPSSCP staging area
+        cls.temp_dir = tempfile.mkdtemp()
+        cls.test_logger.info(f"{cls.temp_dir} directory created")
+
+        # Start the UPS SCP Server (replace with your actual SCP command)
+        command = [
+            "./tdwii_plus_examples/cli/upsscp/upsscp.py",
+            "-v",
+            "--database-location",
+            f"{cls.temp_dir}/test.sqlite",
+            "--instance-location",
+            cls.temp_dir,
+        ]
+
+        cls.upsscp_started, cls.process, cls.stdout_thread = start_scp(command, cls.test_logger)
+        cls.test_logger.info(f"Started process with PID: {cls.process.pid}")
+
+        # Create the UPSPullNActionSCU instance
+        cls.scu = UPSPullNActionSCU(
+            calling_ae_title="UPSPULLSCU",
+            called_ae_title="ANYSCP",
+            called_ip="localhost",
+            called_port=11114,
+            logger=cls.scu_logger,
+        )
+
+    @classmethod
+    def tearDownClass(cls):
+        # Remove the temporary directory and its contents
+        files_in_temp_dir = os.listdir(cls.temp_dir)
+        shutil.rmtree(cls.temp_dir)
+        cls.test_logger.info(f"Removed the temp directory: {cls.temp_dir} and its contents: {files_in_temp_dir}")
+
+        # Try to gracefully terminate the process.
path: tdwii_plus_examples/tests/test_upspullnactionscu_integration.py
user: login: dwikler
body: Yes, modified.
created_at: 2025-05-04T14:56:36Z
author_association: COLLABORATOR
diff_hunk: @@ -168,7 +174,7 @@ def test_set_contexts_valid(self):
 
     def test_set_contexts_invalid(self):
         """Test set_contexts with an invalid context."""
-        qr_abstract__syntax = "1.2.840.10008.5.1.4.1.2.2.1"  # not a storage context
+        qr_abstract__syntax = StudyRootQueryRetrieveInformationModelFind  # not a storage context
path: tdwii_plus_examples/tests/test_cstorescu_unit.py
user: login: sjswerdloff
body: did you really intend to have __ (double underscore) in the variable name?
created_at: 2025-05-05T02:23:53Z
author_association: OWNER
diff_hunk: @@ -168,7 +174,7 @@ def test_set_contexts_valid(self):
 
     def test_set_contexts_invalid(self):
         """Test set_contexts with an invalid context."""
-        qr_abstract__syntax = "1.2.840.10008.5.1.4.1.2.2.1"  # not a storage context
+        qr_abstract__syntax = StudyRootQueryRetrieveInformationModelFind  # not a storage context
path: tdwii_plus_examples/tests/test_cstorescu_unit.py
user: login: dwikler
body: Definitely a typo, I just corrected it.
created_at: 2025-05-10T16:30:41Z
author_association: COLLABORATOR

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-05-03T12:20:29Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide

This pull request refactors the `NActionSCU` class into `UPSPullNActionSCU`, deriving it from a new `BaseSCU` class that centralizes common SCU functionalities like AE setup, association management, and C-ECHO verification. The association logic in `BaseSCU` is enhanced to differentiate between required and optional SOP classes. The corresponding CLI application is renamed, moved, and updated. Existing SCU classes (`CStoreSCU`, `UPSPushNCreateSCU`) and the GUI (`tdd_widget`) are updated to use the new base class and refactored components. New tests are added for the refactored class and CLI.

#### Sequence diagram for Updated GUI Interaction with UPSPullNActionSCU

```mermaid
sequenceDiagram
  participant TDDWidget as GUI
  participant UPSPullSCU as UPSPullNActionSCU

  Note over GUI, UPSPullSCU: Previous interaction used NActionSCU.send_procedure_step_state_change()

  TDDWidget->>+UPSPullSCU: claim_ups(ups_uid)
  UPSPullSCU-->>-TDDWidget: transaction_uid

  TDDWidget->>+UPSPullSCU: cancel_ups(ups_uid, transaction_uid)
  UPSPullSCU-->>-TDDWidget: success/failure

  TDDWidget->>+UPSPullSCU: complete_ups(ups_uid, transaction_uid)
  UPSPullSCU-->>-TDDWidget: success/failure
```

#### Sequence diagram for New CLI (upschangestatescu) Interaction

```mermaid
sequenceDiagram
  participant CLI as upschangestatescu
  participant UPSPullSCU as UPSPullNActionSCU
  participant SCP as Remote AE

  CLI->>+UPSPullSCU: claim_ups(ups_uid)
  UPSPullSCU->>+SCP: N-ACTION(IN PROGRESS, tx_uid=new)
  SCP-->>-UPSPullSCU: N-ACTION Response
  UPSPullSCU-->>-CLI: transaction_uid

  CLI->>+UPSPullSCU: cancel_ups(ups_uid, tx_uid)
  UPSPullSCU->>+SCP: N-ACTION(CANCELED, tx_uid=provided)
  SCP-->>-UPSPullSCU: N-ACTION Response
  UPSPullSCU-->>-CLI: success/failure

  CLI->>+UPSPullSCU: complete_ups(ups_uid, tx_uid)
  UPSPullSCU->>+SCP: N-ACTION(COMPLETED, tx_uid=provided)
  SCP-->>-UPSPullSCU: N-ACTION Response
  UPSPullSCU-->>-CLI: success/failure
```

#### Class diagram for SCU Refactoring

```mermaid
classDiagram
  note "NActionSCU refactored into UPSPullNActionSCU, inheriting from new BaseSCU"
  class BaseSCU{
    +__init__(calling_ae_title, called_ip, called_port, called_ae_title)
    #_add_requested_context()
    #_associate(required_sop_classes, verbose)
    #_attempt_association(required_sop_classes)
    #_check_accepted_sop_classes(assoc)
    #_validate_sop_classes(assoc_result, required_sop_classes)
    #_handle_response(rsp_status, rsp_dataset)
    #_get_status_description(status_code)
    +set_called_ae(called_ip, called_port, called_ae_title)
    +verify()
  }
  class UPSPullNActionSCU{
    -ups_transaction_info : dict
    +__init__(logger, calling_ae_title, called_ip, called_port, called_ae_title)
    #_add_requested_context()
    #_get_status_description(status_code)
    +claim_ups(sop_instance_uid) : UID
    +complete_ups(sop_instance_uid, transaction_uid) : bool
    +cancel_ups(sop_instance_uid, transaction_uid) : bool
    -_close_ups(sop_instance_uid, transaction_uid, state)
    -_change_ups_state(sop_instance_uid, action_info)
  }
  class CStoreSCU{
    +store_instances(instances)
    note for CStoreSCU "Uses BaseSCU._associate() now"
  }
  class UPSPushNCreateSCU{
    +create_ups_instances(instances)
    note for UPSPushNCreateSCU "Uses BaseSCU._associate() now"
  }
  class TDDWidget{
    -_start_procedure()
    -_cancel_procedure()
    -_complete_procedure()
    note for TDDWidget "Uses UPSPullNActionSCU methods now"
  }

  BaseSCU <|-- UPSPullNActionSCU
  BaseSCU <|-- CStoreSCU
  BaseSCU <|-- UPSPushNCreateSCU
  TDDWidget ..> UPSPullNActionSCU : uses
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Refactored `NActionSCU` into `UPSPullNActionSCU` derived from `BaseSCU`. | <ul><li>Renamed `NActionSCU` class to `UPSPullNActionSCU` and moved it to `upspullnactionscu.py`.</li><li>Derived `UPSPullNActionSCU` from the new `BaseSCU`.</li><li>Implemented UPS state change methods (`claim_ups`, `complete_ups`, `cancel_ups`) within `UPSPullNActionSCU`.</li><li>Updated the GUI (`tdd_widget.py`) to instantiate and use `UPSPullNActionSCU` instead of `NActionSCU`.</li></ul> | `tdwii_plus_examples/upspullnactionscu.py`<br/>`tdwii_plus_examples/tdd/tdd_widget.py` |
| Introduced `BaseSCU` class for common SCU functionalities and refined association logic. | <ul><li>Created `BaseSCU` containing AE configuration, C-ECHO (`verify`), association (`_associate`), and response handling (`_handle_response`).</li><li>Refactored `_associate` method to check for accepted SOP classes and validate required ones, returning more informative status.</li><li>Added `_check_accepted_sop_classes` and `_validate_sop_classes` helper methods for association.</li><li>Added `_get_status_description` method for status code lookup, intended for subclass override.</li><li>Added `set_called_ae` method for setting AE parameters post-initialization.</li><li>Updated unit tests for `BaseSCU`.</li></ul> | `tdwii_plus_examples/basescu.py`<br/>`tdwii_plus_examples/tests/test_basescu_unit.py` |
| Refactored the `nactionscu` CLI application. | <ul><li>Renamed `nactionscu.py` CLI script to `upschangestatescu.py` and moved it to the `cli` sub-directory.</li><li>Updated CLI arguments and logic to use `UPSPullNActionSCU` for state changes (claim, cancel, complete).</li><li>Added argument validation for dependencies between `state` and `transaction_uid`.</li><li>Added unit tests for the new CLI script.</li></ul> | `tdwii_plus_examples/cli/upschangestatescu.py`<br/>`tdwii_plus_examples/tests/cli/test_upschangestatescu.py` |
| Updated existing SCU classes (`CStoreSCU`, `UPSPushNCreateSCU`) to leverage `BaseSCU`. | <ul><li>Modified `store_instances` in `CStoreSCU` to call `_associate` with required SOP classes derived from input instances.</li><li>Modified `create_ups_instances` in `UPSPushNCreateSCU` to call `_associate` requiring `UnifiedProcedureStepPush`.</li><li>Removed the now redundant `_handle_association_status` method from both `CStoreSCU` and `UPSPushNCreateSCU`.</li><li>Updated corresponding unit and integration tests.</li></ul> | `tdwii_plus_examples/cstorescu.py`<br/>`tdwii_plus_examples/upspushncreatescu.py`<br/>`tdwii_plus_examples/tests/test_cstorescu_unit.py`<br/>`tdwii_plus_examples/tests/test_cstorescu_integration.py` |
| Added integration tests for `UPSPullNActionSCU`. | <ul><li>Created integration tests that start a local `upsscp`, create a UPS instance using `UPSPushNCreateSCU`, and then use `UPSPullNActionSCU` to claim, complete, or cancel it.</li></ul> | `tdwii_plus_examples/tests/test_upspullnactionscu_integration.py` |
| Improved argument parsing in `storescu` CLI. | <ul><li>Made the `path` argument optional (`nargs='*'`).</li><li>Added validation to ensure either `--echo` or a `path` is provided.</li><li>Added a warning if both `--echo` and `path` are provided.</li></ul> | `tdwii_plus_examples/cli/storescu.py` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: CONTRIBUTOR
user: login: sjswerdloff
created_at: 2025-05-03T12:36:23Z
body: Ask sourcery to create issues for each of its suggestions so we capture them and move forward 
author_association: OWNER
user: login: dwikler
created_at: 2025-05-04T16:18:59Z
body: I addressed all comments except the split between unit and integration tests. It seems unittest does not provide such decorators but there is a way to group tests in test suites. I agree that optimizing the CI test pipeline will be required at some point.


author_association: COLLABORATOR