From 90103c4f46027539d5783831bd31a9c0691113e9 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 11:34:44 +0000
Subject: [PATCH 01/30] CLI: First pass

---
 qiskit_ibm_runtime/cli.py | 158 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 158 insertions(+)
 create mode 100644 qiskit_ibm_runtime/cli.py

diff --git a/qiskit_ibm_runtime/cli.py b/qiskit_ibm_runtime/cli.py
new file mode 100644
index 000000000..f5600210d
--- /dev/null
+++ b/qiskit_ibm_runtime/cli.py
@@ -0,0 +1,158 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2024.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals
+import sys
+from getpass import getpass
+from typing import Literal, Callable
+
+from ibm_cloud_sdk_core.api_exception import ApiException
+
+from .qiskit_runtime_service import QiskitRuntimeService
+from .exceptions import IBMNotAuthorizedError
+from .api.exceptions import RequestsApiError
+from .accounts.management import AccountManager, _DEFAULT_ACCOUNT_CONFIG_JSON_FILE
+from .accounts.exceptions import AccountAlreadyExistsError
+
+Channel = Literal["ibm_quantum", "ibm_cloud"]
+
+def save_account() -> None:
+    """
+    A CLI that guides users through getting their account information and saving it to disk.
+    """
+    try:
+        CLI.main()
+    except KeyboardInterrupt:
+        sys.exit()
+
+class CLI:
+    @classmethod
+    def main(self) -> None:
+        self.print_box(["Qiskit IBM Runtime account setup"])
+        channel = self.get_channel()
+        token = self.get_token(channel)
+        print("Verifying, this might take few seconds...")
+        try:
+            service = QiskitRuntimeService(channel=channel, token=token)
+        except (ApiException, IBMNotAuthorizedError, RequestsApiError) as err:
+            print(
+                Format.red(Format.bold("\nError while authorizing with your token\n"))
+                + Format.red(err.message)
+            )
+            sys.exit(1)
+        instance = self.get_instance(service)
+        self.save_account({
+            "channel": channel,
+            "token": token,
+            "instance": instance,
+        })
+
+    @classmethod
+    def print_box(self, lines: list[str]) -> None:
+        width = max(len(line) for line in lines)
+        box_lines = [
+            "╭─" + "─"*width + "─╮",
+            *(f"│ {Format.bold(line.ljust(width))} │" for line in lines),
+            "╰─" + "─"*width + "─╯",
+        ]
+        print("\n".join(box_lines))
+
+    @classmethod
+    def get_channel(self) -> Channel:
+        print(Format.bold("Select a channel"))
+        return select_from_list(["ibm_quantum", "ibm_cloud"])
+
+    @classmethod
+    def get_token(self, channel: Channel) -> str:
+        token_url = {
+            "ibm_quantum": "https://quantum.ibm.com",
+            "ibm_cloud": "https://cloud.ibm.com/iam/apikeys",
+        }[channel]
+        print(
+            Format.bold(f"\nPaste your API token")
+            + f"\nYou can get this from {Format.cyan(token_url)}."
+            + "\nFor security, you might not see any feedback when typing."
+        )
+        while True:
+            token = getpass(prompt="Token: ").strip()
+            if token != "":
+                return token
+    
+    @classmethod
+    def get_instance(self, service: QiskitRuntimeService) -> str:
+        instances = service.instances()
+        if len(instances) == 1:
+            instance = instances[0]
+            print(f"Using instance {Format.greenbold(instance)}")
+            return instance
+        print(Format.bold("\nSelect a default instance"))
+        return select_from_list(instances)
+    
+    @classmethod
+    def save_account(self, account):
+        try:
+            AccountManager.save(**account)
+        except AccountAlreadyExistsError:
+            response = user_input(
+                message="\nDefault account already exists, would you like to overwrite it? (y/N):",
+                is_valid=lambda response: response.strip().lower() in ["y", "yes", "n", "no", ""]
+            )
+            if response in ["y", "yes"]:
+                AccountManager.save(**account, overwrite=True)
+            else:
+                print("Account not saved.")
+                return
+        
+        print(f"Account saved to {Format.greenbold(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)}")
+        self.print_box([
+            "⚠️ Warning: your token is saved to disk in plain text.",
+            "If on a shared computer, make sure to regenerate your",
+            "token when you're finished.",
+        ])
+
+def user_input(message: str, is_valid: Callable[[str], bool]):
+    while True:
+        response = input(message + " ").strip()
+        if response == "quit":
+            sys.exit()
+        if is_valid(response):
+            return response
+        print("Did not understand input, trying again... (type 'quit' to quit)")
+
+def select_from_list(options: list[str]) -> str:
+    print()
+    for index, option in enumerate(options):
+        print(f"  ({index+1}) {option}")
+    print()
+    response = user_input(
+        message=f"Enter a number 1-{len(options)} and press enter:",
+        is_valid=lambda response: response.isdigit() and int(response) in range(1, len(options)+1)
+    )
+    choice = options[int(response)-1]
+    print(f"Selected {Format.greenbold(choice)}")
+    return choice
+
+class Format:
+    """Format using terminal escape codes"""
+    @classmethod
+    def bold(self, s: str) -> str:
+        return f"\033[1m{s}\033[0m"
+    @classmethod
+    def green(self, s: str) -> str:
+        return f"\033[32m{s}\033[0m"
+    @classmethod
+    def red(self, s: str) -> str:
+        return f"\033[31m{s}\033[0m"
+    @classmethod
+    def cyan(self, s: str) -> str:
+        return f"\033[36m{s}\033[0m"
+    @classmethod
+    def greenbold(self, s: str) -> str:
+        return self.green(self.bold(s))
\ No newline at end of file

From 781fe7ad840c486d2c4803439e306e85f287a74f Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 19:22:35 +0000
Subject: [PATCH 02/30] Make types compatible with Python3.8

---
 qiskit_ibm_runtime/cli.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/qiskit_ibm_runtime/cli.py b/qiskit_ibm_runtime/cli.py
index f5600210d..c74c36dc0 100644
--- a/qiskit_ibm_runtime/cli.py
+++ b/qiskit_ibm_runtime/cli.py
@@ -11,7 +11,7 @@
 # that they have been altered from the originals
 import sys
 from getpass import getpass
-from typing import Literal, Callable
+from typing import List, Literal, Callable
 
 from ibm_cloud_sdk_core.api_exception import ApiException
 
@@ -55,7 +55,7 @@ def main(self) -> None:
         })
 
     @classmethod
-    def print_box(self, lines: list[str]) -> None:
+    def print_box(self, lines: List[str]) -> None:
         width = max(len(line) for line in lines)
         box_lines = [
             "╭─" + "─"*width + "─╮",
@@ -81,7 +81,7 @@ def get_token(self, channel: Channel) -> str:
             + "\nFor security, you might not see any feedback when typing."
         )
         while True:
-            token = getpass(prompt="Token: ").strip()
+            token = getpass("Token: ").strip()
             if token != "":
                 return token
     
@@ -113,8 +113,8 @@ def save_account(self, account):
         print(f"Account saved to {Format.greenbold(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)}")
         self.print_box([
             "⚠️ Warning: your token is saved to disk in plain text.",
-            "If on a shared computer, make sure to regenerate your",
-            "token when you're finished.",
+            "If on a shared computer, make sure to revoke your token",
+            "by regenerating it in your account settings when finished.",
         ])
 
 def user_input(message: str, is_valid: Callable[[str], bool]):
@@ -126,7 +126,7 @@ def user_input(message: str, is_valid: Callable[[str], bool]):
             return response
         print("Did not understand input, trying again... (type 'quit' to quit)")
 
-def select_from_list(options: list[str]) -> str:
+def select_from_list(options: List[str]) -> str:
     print()
     for index, option in enumerate(options):
         print(f"  ({index+1}) {option}")

From 4c7fb44042c2bab994340d6c885cf05068ea5f11 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 19:23:15 +0000
Subject: [PATCH 03/30] Add tests

---
 test/unit/test_cli.py | 188 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 188 insertions(+)
 create mode 100644 test/unit/test_cli.py

diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
new file mode 100644
index 000000000..76a0ec236
--- /dev/null
+++ b/test/unit/test_cli.py
@@ -0,0 +1,188 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2024.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests CLI that saves user account to disk."""
+from typing import List
+import unittest
+from unittest.mock import patch
+from textwrap import dedent
+
+from qiskit_ibm_runtime.cli import CLI, select_from_list
+
+from qiskit_ibm_runtime.accounts.account import IBM_CLOUD_API_URL, IBM_QUANTUM_API_URL
+from .mock.fake_runtime_service import FakeRuntimeService
+from ..ibm_test_case import IBMTestCase
+
+
+class MockIO:
+    """Mock `input` and `getpass`"""
+
+    def __init__(self, inputs: List[str]):
+        self.inputs = inputs
+        self.output = ""
+
+    def mock_input(self, *args, **kwargs):
+        if args:
+            self.mock_print(args[0])
+        return self.inputs.pop(0)
+
+    def mock_print(self, *args):
+        self.output += " ".join(args) + "\n"
+
+
+class TestCLI(IBMTestCase):
+    """Tests for Account class."""
+
+    def test_select_from_list(self):
+        """Test the `select_from_list` helper function"""
+        self.maxDiff = 1500
+
+        # Check a bunch of invalid inputs before entering a valid one
+        mockio = MockIO(["", "0", "-1", "3.14", "9", " 3"])
+
+        @patch("builtins.input", mockio.mock_input)
+        @patch("builtins.print", mockio.mock_print)
+        def run_test():
+            choice = select_from_list(["a", "b", "c", "d"])
+            self.assertEqual(choice, "c")
+
+        run_test()
+        self.assertEqual(mockio.inputs, [])
+        self.assertEqual(
+            mockio.output,
+            dedent(
+                """
+              (1) a
+              (2) b
+              (3) c
+              (4) d
+
+            Enter a number 1-4 and press enter: 
+            Did not understand input, trying again... (type 'quit' to quit)
+            Enter a number 1-4 and press enter: 
+            Did not understand input, trying again... (type 'quit' to quit)
+            Enter a number 1-4 and press enter: 
+            Did not understand input, trying again... (type 'quit' to quit)
+            Enter a number 1-4 and press enter: 
+            Did not understand input, trying again... (type 'quit' to quit)
+            Enter a number 1-4 and press enter: 
+            Did not understand input, trying again... (type 'quit' to quit)
+            Enter a number 1-4 and press enter: 
+            Selected \033[32m\033[1mc\033[0m\033[0m
+            """
+            ),
+        )
+
+    def test_cli_multiple_instances_saved_account(self):
+        """Test a runthrough of the CLI when the user has access to many
+        instances and already has an account saved
+        """
+        token = "Password123"
+        instances = ["my/instance/1", "my/instance/2", "my/instance/3"]
+        selected_instance = 2  # == instances[1]
+
+        class MockRuntimeService:
+            def __init__(*args, **kwargs):
+                pass
+
+            def instances(self):
+                return instances
+
+        expected_saved_account = dedent(
+            f"""
+            {{
+                "default": {{
+                    "channel": "ibm_quantum",
+                    "instance": "{instances[selected_instance-1]}",
+                    "private_endpoint": false,
+                    "token": "{token}",
+                    "url": "{IBM_QUANTUM_API_URL}"
+                }}
+            }}
+        """
+        )
+
+        existing_account = dedent(
+            """
+            {
+                "default": {
+                    "channel": "ibm_quantum",
+                    "instance": "my/instance/0",
+                    "private_endpoint": false,
+                    "token": "super-secret-token",
+                    "url": "https://auth.quantum-computing.ibm.com/api"
+                }
+            }
+            """
+        )
+
+        mockio = MockIO(["1", token, str(selected_instance), "yes"])
+        mock_open = unittest.mock.mock_open(read_data=existing_account)
+
+        @patch("builtins.input", mockio.mock_input)
+        @patch("builtins.open", mock_open)
+        @patch("builtins.print", mockio.mock_print)
+        @patch("qiskit_ibm_runtime.cli.getpass", mockio.mock_input)
+        @patch("qiskit_ibm_runtime.cli.QiskitRuntimeService", MockRuntimeService)
+        def run_cli():
+            CLI.main()
+
+        run_cli()
+        self.assertEqual(mockio.inputs, [])
+
+        written_output = "".join(call.args[0] for call in mock_open().write.mock_calls)
+        self.assertEqual(written_output.strip(), expected_saved_account.strip())
+
+    def test_cli_one_instance_no_saved_account(self):
+        """Test a runthrough of the CLI when the user only has access to one
+        instance and has no account saved.
+        """
+        token = "QJjjbOxSfzZiskMZiyty"
+        instance = "my/only/instance"
+
+        class MockRuntimeService:
+            def __init__(*args, **kwargs):
+                pass
+
+            def instances(self):
+                return [instance]
+
+        expected_saved_account = dedent(
+            f"""
+            {{
+                "default": {{
+                    "channel": "ibm_cloud",
+                    "instance": "{instance}",
+                    "private_endpoint": false,
+                    "token": "{token}",
+                    "url": "{IBM_CLOUD_API_URL}"
+                }}
+            }}
+        """
+        )
+
+        mockio = MockIO(["2", token])
+        mock_open = unittest.mock.mock_open(read_data="{}")
+
+        @patch("builtins.input", mockio.mock_input)
+        @patch("builtins.open", mock_open)
+        @patch("builtins.print", mockio.mock_print)
+        @patch("qiskit_ibm_runtime.cli.getpass", mockio.mock_input)
+        @patch("qiskit_ibm_runtime.cli.QiskitRuntimeService", MockRuntimeService)
+        def run_cli():
+            CLI.main()
+
+        run_cli()
+        self.assertEqual(mockio.inputs, [])
+
+        written_output = "".join(call.args[0] for call in mock_open().write.mock_calls)
+        self.assertEqual(written_output.strip(), expected_saved_account.strip())

From 6446c5d4fc714088c8b9099e11829832b2d626a2 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 19:26:26 +0000
Subject: [PATCH 04/30] Make CLI module private

---
 qiskit_ibm_runtime/{cli.py => _cli.py} |  0
 test/unit/test_cli.py                  | 10 +++++-----
 2 files changed, 5 insertions(+), 5 deletions(-)
 rename qiskit_ibm_runtime/{cli.py => _cli.py} (100%)

diff --git a/qiskit_ibm_runtime/cli.py b/qiskit_ibm_runtime/_cli.py
similarity index 100%
rename from qiskit_ibm_runtime/cli.py
rename to qiskit_ibm_runtime/_cli.py
diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index 76a0ec236..f68f60c0a 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -16,7 +16,7 @@
 from unittest.mock import patch
 from textwrap import dedent
 
-from qiskit_ibm_runtime.cli import CLI, select_from_list
+from qiskit_ibm_runtime._cli import CLI, select_from_list
 
 from qiskit_ibm_runtime.accounts.account import IBM_CLOUD_API_URL, IBM_QUANTUM_API_URL
 from .mock.fake_runtime_service import FakeRuntimeService
@@ -131,8 +131,8 @@ def instances(self):
         @patch("builtins.input", mockio.mock_input)
         @patch("builtins.open", mock_open)
         @patch("builtins.print", mockio.mock_print)
-        @patch("qiskit_ibm_runtime.cli.getpass", mockio.mock_input)
-        @patch("qiskit_ibm_runtime.cli.QiskitRuntimeService", MockRuntimeService)
+        @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
+        @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
             CLI.main()
 
@@ -176,8 +176,8 @@ def instances(self):
         @patch("builtins.input", mockio.mock_input)
         @patch("builtins.open", mock_open)
         @patch("builtins.print", mockio.mock_print)
-        @patch("qiskit_ibm_runtime.cli.getpass", mockio.mock_input)
-        @patch("qiskit_ibm_runtime.cli.QiskitRuntimeService", MockRuntimeService)
+        @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
+        @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
             CLI.main()
 

From 2b9778821dbae1d235bd52b14677e4c96c0411d2 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 19:36:13 +0000
Subject: [PATCH 05/30] Add --help

---
 qiskit_ibm_runtime/_cli.py | 20 +++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index c74c36dc0..e82f0cf30 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -9,6 +9,7 @@
 # Any modifications or derivative works of this code must retain this
 # copyright notice, and modified files need to carry a notice indicating
 # that they have been altered from the originals
+import argparse
 import sys
 from getpass import getpass
 from typing import List, Literal, Callable
@@ -23,19 +24,36 @@
 
 Channel = Literal["ibm_quantum", "ibm_cloud"]
 
+SCRIPT_NAME = "Qiskit IBM Runtime save account"
+
+
 def save_account() -> None:
     """
     A CLI that guides users through getting their account information and saving it to disk.
     """
+    # Use argparse to create the --help feature
+    parser = argparse.ArgumentParser(
+        prog=SCRIPT_NAME,
+        description=dedent(
+            """
+            An interactive command-line interface to save your Qiskit IBM 
+            Runtime account locally. This script is interactive-only and takes 
+            no arguments
+            """
+        ),
+    )
+    parser.parse_args()
+
     try:
         CLI.main()
     except KeyboardInterrupt:
         sys.exit()
 
+
 class CLI:
     @classmethod
     def main(self) -> None:
-        self.print_box(["Qiskit IBM Runtime account setup"])
+        self.print_box([SCRIPT_NAME])
         channel = self.get_channel()
         token = self.get_token(channel)
         print("Verifying, this might take few seconds...")

From 25f2275b520667aea8d07889e9944a23d9f5d161 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 20:33:06 +0000
Subject: [PATCH 06/30] Add `save-account` as subcommand

---
 qiskit_ibm_runtime/_cli.py | 43 ++++++++++++++++++++++++++------------
 setup.py                   |  3 +++
 2 files changed, 33 insertions(+), 13 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index e82f0cf30..87c587233 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -24,26 +24,43 @@
 
 Channel = Literal["ibm_quantum", "ibm_cloud"]
 
-SCRIPT_NAME = "Qiskit IBM Runtime save account"
 
-
-def save_account() -> None:
+def entry_point() -> None:
     """
-    A CLI that guides users through getting their account information and saving it to disk.
+    This is the entry point for the `qiskit-ibm-runtime` command. At the
+    moment, we only support one script (save-account), but we want to have a
+    `qiskit-ibm-runtime` command so users can run `pipx run qiskit-ibm-runtime
+    save-account`.
     """
     # Use argparse to create the --help feature
     parser = argparse.ArgumentParser(
-        prog=SCRIPT_NAME,
-        description=dedent(
-            """
-            An interactive command-line interface to save your Qiskit IBM 
-            Runtime account locally. This script is interactive-only and takes 
-            no arguments
-            """
+        prog="qiskit-ibm-runtime",
+        description="Scripts for the Qiskit IBM Runtime Python package",
+    )
+    subparsers = parser.add_subparsers(
+        title="Scripts",
+        description="This package supports the following scripts:",
+        dest="script",
+        required=True,
+    )
+    save_account_subparser = subparsers.add_parser(
+        "save-account",
+        description=(
+            "An interactive command-line interface to save your Qiskit IBM "
+            "Runtime account locally. This script is interactive-only and takes "
+            "no arguments."
         ),
+        help="Interactive command-line interface to save your account locally.",
     )
-    parser.parse_args()
+    args = parser.parse_args()
+    if args.script == "save-account":
+        save_account()
 
+
+def save_account() -> None:
+    """
+    A CLI that guides users through getting their account information and saving it to disk.
+    """
     try:
         CLI.main()
     except KeyboardInterrupt:
@@ -53,7 +70,7 @@ def save_account() -> None:
 class CLI:
     @classmethod
     def main(self) -> None:
-        self.print_box([SCRIPT_NAME])
+        self.print_box(["Qiskit IBM Runtime save account"])
         channel = self.get_channel()
         token = self.get_token(channel)
         print("Verifying, this might take few seconds...")
diff --git a/setup.py b/setup.py
index 6875c53d7..e23c88d12 100644
--- a/setup.py
+++ b/setup.py
@@ -82,6 +82,9 @@
         "qiskit.transpiler.translation": [
             "ibm_backend = qiskit_ibm_runtime.transpiler.plugin:IBMTranslationPlugin",
             "ibm_dynamic_circuits = qiskit_ibm_runtime.transpiler.plugin:IBMDynamicTranslationPlugin",
+        ],
+        "console_scripts": [
+            "qiskit-ibm-runtime = qiskit_ibm_runtime._cli:entry_point"
         ]
     },
     extras_require={"visualization": ["plotly>=5.23.0"]},

From 2c21d524fb9fd7c05dc96f59e08739c76cf8b4a6 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 20:53:24 +0000
Subject: [PATCH 07/30] Neaten, lint, and format

---
 qiskit_ibm_runtime/_cli.py | 128 ++++++++++++++++++++++++-------------
 test/unit/test_cli.py      |  19 +++---
 2 files changed, 93 insertions(+), 54 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 87c587233..ff233dd10 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -9,6 +9,9 @@
 # Any modifications or derivative works of this code must retain this
 # copyright notice, and modified files need to carry a notice indicating
 # that they have been altered from the originals
+"""
+The `save-account` command-line interface. These classes and functions are not public.
+"""
 import argparse
 import sys
 from getpass import getpass
@@ -43,7 +46,7 @@ def entry_point() -> None:
         dest="script",
         required=True,
     )
-    save_account_subparser = subparsers.add_parser(
+    subparsers.add_parser(
         "save-account",
         description=(
             "An interactive command-line interface to save your Qiskit IBM "
@@ -54,25 +57,26 @@ def entry_point() -> None:
     )
     args = parser.parse_args()
     if args.script == "save-account":
-        save_account()
+        try:
+            SaveAccountCLI.main()
+        except KeyboardInterrupt:
+            sys.exit()
 
 
-def save_account() -> None:
+class SaveAccountCLI:
     """
-    A CLI that guides users through getting their account information and saving it to disk.
+    This class contains the save-account command and helper functions.
     """
-    try:
-        CLI.main()
-    except KeyboardInterrupt:
-        sys.exit()
-
 
-class CLI:
     @classmethod
-    def main(self) -> None:
-        self.print_box(["Qiskit IBM Runtime save account"])
-        channel = self.get_channel()
-        token = self.get_token(channel)
+    def main(cls) -> None:
+        """
+        A CLI that guides users through getting their account information and
+        saving it to disk.
+        """
+        cls.print_box(["Qiskit IBM Runtime save account"])
+        channel = cls.get_channel()
+        token = cls.get_token(channel)
         print("Verifying, this might take few seconds...")
         try:
             service = QiskitRuntimeService(channel=channel, token=token)
@@ -82,36 +86,41 @@ def main(self) -> None:
                 + Format.red(err.message)
             )
             sys.exit(1)
-        instance = self.get_instance(service)
-        self.save_account({
-            "channel": channel,
-            "token": token,
-            "instance": instance,
-        })
+        instance = cls.get_instance(service)
+        cls.save_to_disk(
+            {
+                "channel": channel,
+                "token": token,
+                "instance": instance,
+            }
+        )
 
     @classmethod
-    def print_box(self, lines: List[str]) -> None:
+    def print_box(cls, lines: List[str]) -> None:
+        """Print lines in a box using Unicode box-drawing characters"""
         width = max(len(line) for line in lines)
         box_lines = [
-            "╭─" + "─"*width + "─╮",
+            "╭─" + "─" * width + "─╮",
             *(f"│ {Format.bold(line.ljust(width))} │" for line in lines),
-            "╰─" + "─"*width + "─╯",
+            "╰─" + "─" * width + "─╯",
         ]
         print("\n".join(box_lines))
 
     @classmethod
-    def get_channel(self) -> Channel:
+    def get_channel(cls) -> Channel:
+        """Ask user which channel to use"""
         print(Format.bold("Select a channel"))
         return select_from_list(["ibm_quantum", "ibm_cloud"])
 
     @classmethod
-    def get_token(self, channel: Channel) -> str:
+    def get_token(cls, channel: Channel) -> str:
+        """Ask user for their token"""
         token_url = {
             "ibm_quantum": "https://quantum.ibm.com",
             "ibm_cloud": "https://cloud.ibm.com/iam/apikeys",
         }[channel]
         print(
-            Format.bold(f"\nPaste your API token")
+            Format.bold("\nPaste your API token")
             + f"\nYou can get this from {Format.cyan(token_url)}."
             + "\nFor security, you might not see any feedback when typing."
         )
@@ -119,9 +128,13 @@ def get_token(self, channel: Channel) -> str:
             token = getpass("Token: ").strip()
             if token != "":
                 return token
-    
+
     @classmethod
-    def get_instance(self, service: QiskitRuntimeService) -> str:
+    def get_instance(cls, service: QiskitRuntimeService) -> str:
+        """
+        Ask user which instance to use, or select automatically if only one
+        is available.
+        """
         instances = service.instances()
         if len(instances) == 1:
             instance = instances[0]
@@ -129,30 +142,42 @@ def get_instance(self, service: QiskitRuntimeService) -> str:
             return instance
         print(Format.bold("\nSelect a default instance"))
         return select_from_list(instances)
-    
+
     @classmethod
-    def save_account(self, account):
+    def save_to_disk(cls, account):
+        """
+        Save account details to disk, confirming if they'd like to overwrite if
+        one exists already. Display a warning that token is stored in plain
+        text.
+        """
         try:
             AccountManager.save(**account)
         except AccountAlreadyExistsError:
             response = user_input(
                 message="\nDefault account already exists, would you like to overwrite it? (y/N):",
-                is_valid=lambda response: response.strip().lower() in ["y", "yes", "n", "no", ""]
+                is_valid=lambda response: response.strip().lower() in ["y", "yes", "n", "no", ""],
             )
             if response in ["y", "yes"]:
                 AccountManager.save(**account, overwrite=True)
             else:
                 print("Account not saved.")
                 return
-        
+
         print(f"Account saved to {Format.greenbold(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)}")
-        self.print_box([
-            "⚠️ Warning: your token is saved to disk in plain text.",
-            "If on a shared computer, make sure to revoke your token",
-            "by regenerating it in your account settings when finished.",
-        ])
+        cls.print_box(
+            [
+                "⚠️ Warning: your token is saved to disk in plain text.",
+                "If on a shared computer, make sure to revoke your token",
+                "by regenerating it in your account settings when finished.",
+            ]
+        )
+
 
 def user_input(message: str, is_valid: Callable[[str], bool]):
+    """
+    Repeatedly ask user for input until they give us something that satisifies
+    `is_valid`.
+    """
     while True:
         response = input(message + " ").strip()
         if response == "quit":
@@ -161,33 +186,46 @@ def user_input(message: str, is_valid: Callable[[str], bool]):
             return response
         print("Did not understand input, trying again... (type 'quit' to quit)")
 
+
 def select_from_list(options: List[str]) -> str:
+    """
+    Prompt user to select from a list of options by entering a number.
+    """
     print()
     for index, option in enumerate(options):
         print(f"  ({index+1}) {option}")
     print()
     response = user_input(
         message=f"Enter a number 1-{len(options)} and press enter:",
-        is_valid=lambda response: response.isdigit() and int(response) in range(1, len(options)+1)
+        is_valid=lambda response: response.isdigit()
+        and int(response) in range(1, len(options) + 1),
     )
-    choice = options[int(response)-1]
+    choice = options[int(response) - 1]
     print(f"Selected {Format.greenbold(choice)}")
     return choice
 
+
 class Format:
     """Format using terminal escape codes"""
+
+    # pylint: disable=missing-function-docstring
+
     @classmethod
-    def bold(self, s: str) -> str:
+    def bold(cls, s: str) -> str:
         return f"\033[1m{s}\033[0m"
+
     @classmethod
-    def green(self, s: str) -> str:
+    def green(cls, s: str) -> str:
         return f"\033[32m{s}\033[0m"
+
     @classmethod
-    def red(self, s: str) -> str:
+    def red(cls, s: str) -> str:
         return f"\033[31m{s}\033[0m"
+
     @classmethod
-    def cyan(self, s: str) -> str:
+    def cyan(cls, s: str) -> str:
         return f"\033[36m{s}\033[0m"
+
     @classmethod
-    def greenbold(self, s: str) -> str:
-        return self.green(self.bold(s))
\ No newline at end of file
+    def greenbold(cls, s: str) -> str:
+        return cls.green(cls.bold(s))
diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index f68f60c0a..3a90fca77 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -16,21 +16,21 @@
 from unittest.mock import patch
 from textwrap import dedent
 
-from qiskit_ibm_runtime._cli import CLI, select_from_list
+from qiskit_ibm_runtime._cli import SaveAccountCLI, select_from_list
 
 from qiskit_ibm_runtime.accounts.account import IBM_CLOUD_API_URL, IBM_QUANTUM_API_URL
-from .mock.fake_runtime_service import FakeRuntimeService
 from ..ibm_test_case import IBMTestCase
 
 
 class MockIO:
     """Mock `input` and `getpass`"""
+    # pylint: disable=missing-function-docstring
 
     def __init__(self, inputs: List[str]):
         self.inputs = inputs
         self.output = ""
 
-    def mock_input(self, *args, **kwargs):
+    def mock_input(self, *args, **_kwargs):
         if args:
             self.mock_print(args[0])
         return self.inputs.pop(0)
@@ -40,11 +40,12 @@ def mock_print(self, *args):
 
 
 class TestCLI(IBMTestCase):
-    """Tests for Account class."""
+    """Tests for the save-account CLI."""
+    # pylint: disable=missing-class-docstring, missing-function-docstring
 
     def test_select_from_list(self):
         """Test the `select_from_list` helper function"""
-        self.maxDiff = 1500
+        self.maxDiff = 1500  # pylint: disable=invalid-name
 
         # Check a bunch of invalid inputs before entering a valid one
         mockio = MockIO(["", "0", "-1", "3.14", "9", " 3"])
@@ -91,7 +92,7 @@ def test_cli_multiple_instances_saved_account(self):
         selected_instance = 2  # == instances[1]
 
         class MockRuntimeService:
-            def __init__(*args, **kwargs):
+            def __init__(self, *_args, **_kwargs):
                 pass
 
             def instances(self):
@@ -134,7 +135,7 @@ def instances(self):
         @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
         @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
-            CLI.main()
+            SaveAccountCLI.main()
 
         run_cli()
         self.assertEqual(mockio.inputs, [])
@@ -150,7 +151,7 @@ def test_cli_one_instance_no_saved_account(self):
         instance = "my/only/instance"
 
         class MockRuntimeService:
-            def __init__(*args, **kwargs):
+            def __init__(self, *_args, **_kwargs):
                 pass
 
             def instances(self):
@@ -179,7 +180,7 @@ def instances(self):
         @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
         @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
-            CLI.main()
+            SaveAccountCLI.main()
 
         run_cli()
         self.assertEqual(mockio.inputs, [])

From d3407584e5ccd4810d69b97c97ce7b8e1dccc408 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 21:13:50 +0000
Subject: [PATCH 08/30] Update qiskit_ibm_runtime/_cli.py

---
 qiskit_ibm_runtime/_cli.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index ff233dd10..ab4b30936 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -157,7 +157,7 @@ def save_to_disk(cls, account):
                 message="\nDefault account already exists, would you like to overwrite it? (y/N):",
                 is_valid=lambda response: response.strip().lower() in ["y", "yes", "n", "no", ""],
             )
-            if response in ["y", "yes"]:
+            if response.lower() in ["y", "yes"]:
                 AccountManager.save(**account, overwrite=True)
             else:
                 print("Account not saved.")

From dbc307b45067b0ad2112fa35a5b15be2990b4041 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 21:52:44 +0000
Subject: [PATCH 09/30] black

---
 test/unit/test_cli.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index 3a90fca77..881bb002d 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -24,6 +24,7 @@
 
 class MockIO:
     """Mock `input` and `getpass`"""
+
     # pylint: disable=missing-function-docstring
 
     def __init__(self, inputs: List[str]):
@@ -41,6 +42,7 @@ def mock_print(self, *args):
 
 class TestCLI(IBMTestCase):
     """Tests for the save-account CLI."""
+
     # pylint: disable=missing-class-docstring, missing-function-docstring
 
     def test_select_from_list(self):

From fd671aa247383022a8c71e5ef45406df398fefb4 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 22:04:13 +0000
Subject: [PATCH 10/30] Fix apache header

---
 qiskit_ibm_runtime/_cli.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index ab4b30936..e6f4e4786 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -8,10 +8,12 @@
 #
 # Any modifications or derivative works of this code must retain this
 # copyright notice, and modified files need to carry a notice indicating
-# that they have been altered from the originals
+# that they have been altered from the originals.
+
 """
 The `save-account` command-line interface. These classes and functions are not public.
 """
+
 import argparse
 import sys
 from getpass import getpass

From 1407d38a056b3bd5dfa9f248f9ff4b689398fb26 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 2 Dec 2024 22:45:40 +0000
Subject: [PATCH 11/30] Fix types

---
 qiskit_ibm_runtime/_cli.py | 11 ++++++-----
 1 file changed, 6 insertions(+), 5 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index e6f4e4786..287fc70e5 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -17,7 +17,7 @@
 import argparse
 import sys
 from getpass import getpass
-from typing import List, Literal, Callable
+from typing import List, Literal, Callable, TypeVar
 
 from ibm_cloud_sdk_core.api_exception import ApiException
 
@@ -28,6 +28,7 @@
 from .accounts.exceptions import AccountAlreadyExistsError
 
 Channel = Literal["ibm_quantum", "ibm_cloud"]
+T = TypeVar("T")
 
 
 def entry_point() -> None:
@@ -146,7 +147,7 @@ def get_instance(cls, service: QiskitRuntimeService) -> str:
         return select_from_list(instances)
 
     @classmethod
-    def save_to_disk(cls, account):
+    def save_to_disk(cls, account: dict) -> None:
         """
         Save account details to disk, confirming if they'd like to overwrite if
         one exists already. Display a warning that token is stored in plain
@@ -175,7 +176,7 @@ def save_to_disk(cls, account):
         )
 
 
-def user_input(message: str, is_valid: Callable[[str], bool]):
+def user_input(message: str, is_valid: Callable[[str], bool]) -> str:
     """
     Repeatedly ask user for input until they give us something that satisifies
     `is_valid`.
@@ -189,7 +190,7 @@ def user_input(message: str, is_valid: Callable[[str], bool]):
         print("Did not understand input, trying again... (type 'quit' to quit)")
 
 
-def select_from_list(options: List[str]) -> str:
+def select_from_list(options: List[T]) -> T:
     """
     Prompt user to select from a list of options by entering a number.
     """
@@ -203,7 +204,7 @@ def select_from_list(options: List[str]) -> str:
         and int(response) in range(1, len(options) + 1),
     )
     choice = options[int(response) - 1]
-    print(f"Selected {Format.greenbold(choice)}")
+    print(f"Selected {Format.greenbold(str(choice))}")
     return choice
 
 

From 237b1c053565e7859612a39a3d5812b2837c3227 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Thu, 5 Dec 2024 13:29:05 +0000
Subject: [PATCH 12/30] Update qiskit_ibm_runtime/_cli.py

Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com>
---
 qiskit_ibm_runtime/_cli.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 287fc70e5..fe9810e36 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -11,7 +11,9 @@
 # that they have been altered from the originals.
 
 """
-The `save-account` command-line interface. These classes and functions are not public.
+The `save-account` command-line interface.
+
+These classes and functions are not public.
 """
 
 import argparse

From c0c0f29ebd983807dc72cd7dd34535ce1936ef9a Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Thu, 5 Dec 2024 13:25:34 +0000
Subject: [PATCH 13/30] quit -> q

---
 qiskit_ibm_runtime/_cli.py |  4 ++--
 test/unit/test_cli.py      | 26 +++++++++++++-------------
 2 files changed, 15 insertions(+), 15 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index fe9810e36..9f53749e8 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -185,11 +185,11 @@ def user_input(message: str, is_valid: Callable[[str], bool]) -> str:
     """
     while True:
         response = input(message + " ").strip()
-        if response == "quit":
+        if response in ["q", "quit"]:
             sys.exit()
         if is_valid(response):
             return response
-        print("Did not understand input, trying again... (type 'quit' to quit)")
+        print("Did not understand input, trying again... (or type 'q' to quit)")
 
 
 def select_from_list(options: List[T]) -> T:
diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index 881bb002d..57f179eb1 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -47,7 +47,7 @@ class TestCLI(IBMTestCase):
 
     def test_select_from_list(self):
         """Test the `select_from_list` helper function"""
-        self.maxDiff = 1500  # pylint: disable=invalid-name
+        self.maxDiff = 3000  # pylint: disable=invalid-name
 
         # Check a bunch of invalid inputs before entering a valid one
         mockio = MockIO(["", "0", "-1", "3.14", "9", " 3"])
@@ -69,19 +69,19 @@ def run_test():
               (3) c
               (4) d
 
-            Enter a number 1-4 and press enter: 
-            Did not understand input, trying again... (type 'quit' to quit)
-            Enter a number 1-4 and press enter: 
-            Did not understand input, trying again... (type 'quit' to quit)
-            Enter a number 1-4 and press enter: 
-            Did not understand input, trying again... (type 'quit' to quit)
-            Enter a number 1-4 and press enter: 
-            Did not understand input, trying again... (type 'quit' to quit)
-            Enter a number 1-4 and press enter: 
-            Did not understand input, trying again... (type 'quit' to quit)
-            Enter a number 1-4 and press enter: 
+            Enter a number 1-4 and press enter:•
+            Did not understand input, trying again... (or type 'q' to quit)
+            Enter a number 1-4 and press enter:•
+            Did not understand input, trying again... (or type 'q' to quit)
+            Enter a number 1-4 and press enter:•
+            Did not understand input, trying again... (or type 'q' to quit)
+            Enter a number 1-4 and press enter:•
+            Did not understand input, trying again... (or type 'q' to quit)
+            Enter a number 1-4 and press enter:•
+            Did not understand input, trying again... (or type 'q' to quit)
+            Enter a number 1-4 and press enter:•
             Selected \033[32m\033[1mc\033[0m\033[0m
-            """
+            """.replace("•", " ")
             ),
         )
 

From 51d132cdd43646e0f5840c2c0c7fb845e670c721 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Thu, 5 Dec 2024 13:27:41 +0000
Subject: [PATCH 14/30] Refactor: Format

---
 qiskit_ibm_runtime/_cli.py | 48 +++++++++++++++++++-------------------
 1 file changed, 24 insertions(+), 24 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 9f53749e8..d9f099688 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -79,7 +79,7 @@ def main(cls) -> None:
         A CLI that guides users through getting their account information and
         saving it to disk.
         """
-        cls.print_box(["Qiskit IBM Runtime save account"])
+        print(Format.box(["Qiskit IBM Runtime save account"]))
         channel = cls.get_channel()
         token = cls.get_token(channel)
         print("Verifying, this might take few seconds...")
@@ -100,17 +100,6 @@ def main(cls) -> None:
             }
         )
 
-    @classmethod
-    def print_box(cls, lines: List[str]) -> None:
-        """Print lines in a box using Unicode box-drawing characters"""
-        width = max(len(line) for line in lines)
-        box_lines = [
-            "╭─" + "─" * width + "─╮",
-            *(f"│ {Format.bold(line.ljust(width))} │" for line in lines),
-            "╰─" + "─" * width + "─╯",
-        ]
-        print("\n".join(box_lines))
-
     @classmethod
     def get_channel(cls) -> Channel:
         """Ask user which channel to use"""
@@ -169,13 +158,13 @@ def save_to_disk(cls, account: dict) -> None:
                 return
 
         print(f"Account saved to {Format.greenbold(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)}")
-        cls.print_box(
+        print(Format.box(
             [
                 "⚠️ Warning: your token is saved to disk in plain text.",
                 "If on a shared computer, make sure to revoke your token",
                 "by regenerating it in your account settings when finished.",
             ]
-        )
+        ))
 
 
 def user_input(message: str, is_valid: Callable[[str], bool]) -> str:
@@ -215,22 +204,33 @@ class Format:
 
     # pylint: disable=missing-function-docstring
 
-    @classmethod
-    def bold(cls, s: str) -> str:
+    @staticmethod
+    def box(lines: List[str]) -> str:
+        """Print lines in a box using Unicode box-drawing characters"""
+        width = max(len(line) for line in lines)
+        box_lines = [
+            "╭─" + "─" * width + "─╮",
+            *(f"│ {Format.bold(line.ljust(width))} │" for line in lines),
+            "╰─" + "─" * width + "─╯",
+        ]
+        return "\n".join(box_lines)
+
+    @staticmethod
+    def bold(s: str) -> str:
         return f"\033[1m{s}\033[0m"
 
-    @classmethod
-    def green(cls, s: str) -> str:
+    @staticmethod
+    def green(s: str) -> str:
         return f"\033[32m{s}\033[0m"
 
-    @classmethod
-    def red(cls, s: str) -> str:
+    @staticmethod
+    def red(s: str) -> str:
         return f"\033[31m{s}\033[0m"
 
-    @classmethod
-    def cyan(cls, s: str) -> str:
+    @staticmethod
+    def cyan(s: str) -> str:
         return f"\033[36m{s}\033[0m"
 
-    @classmethod
-    def greenbold(cls, s: str) -> str:
+    @staticmethod
+    def greenbold(s: str) -> str:
         return cls.green(cls.bold(s))

From 4dd96bf88c946db73ffbd308da771b7d04954dae Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Thu, 5 Dec 2024 13:28:00 +0000
Subject: [PATCH 15/30] Minor bug

---
 qiskit_ibm_runtime/_cli.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index d9f099688..9246ceee3 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -151,7 +151,7 @@ def save_to_disk(cls, account: dict) -> None:
                 message="\nDefault account already exists, would you like to overwrite it? (y/N):",
                 is_valid=lambda response: response.strip().lower() in ["y", "yes", "n", "no", ""],
             )
-            if response.lower() in ["y", "yes"]:
+            if response.strip().lower() in ["y", "yes"]:
                 AccountManager.save(**account, overwrite=True)
             else:
                 print("Account not saved.")

From a2e49369d839fd4cd50cbd2a2f4cae7b005e734e Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Thu, 5 Dec 2024 13:30:04 +0000
Subject: [PATCH 16/30] scripts -> commands

---
 qiskit_ibm_runtime/_cli.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 9246ceee3..9083f9c15 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -43,11 +43,11 @@ def entry_point() -> None:
     # Use argparse to create the --help feature
     parser = argparse.ArgumentParser(
         prog="qiskit-ibm-runtime",
-        description="Scripts for the Qiskit IBM Runtime Python package",
+        description="Commands for the Qiskit IBM Runtime Python package",
     )
     subparsers = parser.add_subparsers(
-        title="Scripts",
-        description="This package supports the following scripts:",
+        title="Commands",
+        description="This package supports the following commands:",
         dest="script",
         required=True,
     )

From 093977d0ebcd1ff32262652c4b532e1835e9f0bc Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Thu, 5 Dec 2024 13:36:00 +0000
Subject: [PATCH 17/30] Reorg: UserInput

---
 qiskit_ibm_runtime/_cli.py | 77 +++++++++++++++++++++-----------------
 test/unit/test_cli.py      |  6 +--
 2 files changed, 46 insertions(+), 37 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 9083f9c15..92def233d 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -104,7 +104,7 @@ def main(cls) -> None:
     def get_channel(cls) -> Channel:
         """Ask user which channel to use"""
         print(Format.bold("Select a channel"))
-        return select_from_list(["ibm_quantum", "ibm_cloud"])
+        return UserInput.select_from_list(["ibm_quantum", "ibm_cloud"])
 
     @classmethod
     def get_token(cls, channel: Channel) -> str:
@@ -118,10 +118,7 @@ def get_token(cls, channel: Channel) -> str:
             + f"\nYou can get this from {Format.cyan(token_url)}."
             + "\nFor security, you might not see any feedback when typing."
         )
-        while True:
-            token = getpass("Token: ").strip()
-            if token != "":
-                return token
+        return UserInput.token()
 
     @classmethod
     def get_instance(cls, service: QiskitRuntimeService) -> str:
@@ -135,7 +132,7 @@ def get_instance(cls, service: QiskitRuntimeService) -> str:
             print(f"Using instance {Format.greenbold(instance)}")
             return instance
         print(Format.bold("\nSelect a default instance"))
-        return select_from_list(instances)
+        return UserInput.select_from_list(instances)
 
     @classmethod
     def save_to_disk(cls, account: dict) -> None:
@@ -147,7 +144,7 @@ def save_to_disk(cls, account: dict) -> None:
         try:
             AccountManager.save(**account)
         except AccountAlreadyExistsError:
-            response = user_input(
+            response = UserInput.input(
                 message="\nDefault account already exists, would you like to overwrite it? (y/N):",
                 is_valid=lambda response: response.strip().lower() in ["y", "yes", "n", "no", ""],
             )
@@ -166,37 +163,49 @@ def save_to_disk(cls, account: dict) -> None:
             ]
         ))
 
-
-def user_input(message: str, is_valid: Callable[[str], bool]) -> str:
+class UserInput:
     """
-    Repeatedly ask user for input until they give us something that satisifies
-    `is_valid`.
+    Helper functions to get different types input from user.
     """
-    while True:
-        response = input(message + " ").strip()
-        if response in ["q", "quit"]:
-            sys.exit()
-        if is_valid(response):
-            return response
-        print("Did not understand input, trying again... (or type 'q' to quit)")
 
+    @staticmethod
+    def input(message: str, is_valid: Callable[[str], bool]) -> str:
+        """
+        Repeatedly ask user for input until they give us something that satisifies
+        `is_valid`.
+        """
+        while True:
+            response = input(message + " ").strip()
+            if response in ["q", "quit"]:
+                sys.exit()
+            if is_valid(response):
+                return response
+            print("Did not understand input, trying again... (or type 'q' to quit)")
 
-def select_from_list(options: List[T]) -> T:
-    """
-    Prompt user to select from a list of options by entering a number.
-    """
-    print()
-    for index, option in enumerate(options):
-        print(f"  ({index+1}) {option}")
-    print()
-    response = user_input(
-        message=f"Enter a number 1-{len(options)} and press enter:",
-        is_valid=lambda response: response.isdigit()
-        and int(response) in range(1, len(options) + 1),
-    )
-    choice = options[int(response) - 1]
-    print(f"Selected {Format.greenbold(str(choice))}")
-    return choice
+    @staticmethod
+    def token() -> str:
+        while True:
+            token = getpass("Token: ").strip()
+            if token != "":
+                return token
+
+    @staticmethod
+    def select_from_list(options: List[T]) -> T:
+        """
+        Prompt user to select from a list of options by entering a number.
+        """
+        print()
+        for index, option in enumerate(options):
+            print(f"  ({index+1}) {option}")
+        print()
+        response = UserInput.input(
+            message=f"Enter a number 1-{len(options)} and press enter:",
+            is_valid=lambda response: response.isdigit()
+            and int(response) in range(1, len(options) + 1),
+        )
+        choice = options[int(response) - 1]
+        print(f"Selected {Format.greenbold(str(choice))}")
+        return choice
 
 
 class Format:
diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index 57f179eb1..99a1ed3e6 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -16,7 +16,7 @@
 from unittest.mock import patch
 from textwrap import dedent
 
-from qiskit_ibm_runtime._cli import SaveAccountCLI, select_from_list
+from qiskit_ibm_runtime._cli import SaveAccountCLI, UserInput
 
 from qiskit_ibm_runtime.accounts.account import IBM_CLOUD_API_URL, IBM_QUANTUM_API_URL
 from ..ibm_test_case import IBMTestCase
@@ -46,7 +46,7 @@ class TestCLI(IBMTestCase):
     # pylint: disable=missing-class-docstring, missing-function-docstring
 
     def test_select_from_list(self):
-        """Test the `select_from_list` helper function"""
+        """Test the `UserInput.select_from_list` helper method"""
         self.maxDiff = 3000  # pylint: disable=invalid-name
 
         # Check a bunch of invalid inputs before entering a valid one
@@ -55,7 +55,7 @@ def test_select_from_list(self):
         @patch("builtins.input", mockio.mock_input)
         @patch("builtins.print", mockio.mock_print)
         def run_test():
-            choice = select_from_list(["a", "b", "c", "d"])
+            choice = UserInput.select_from_list(["a", "b", "c", "d"])
             self.assertEqual(choice, "c")
 
         run_test()

From 68a335a76c6ccf408b3c05eb7b83b23501a4650f Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 16 Dec 2024 14:38:49 +0000
Subject: [PATCH 18/30] Add `--no-color` arg

---
 qiskit_ibm_runtime/_cli.py | 124 +++++++++++++++++++++----------------
 test/unit/test_cli.py      |  14 +++--
 2 files changed, 78 insertions(+), 60 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 92def233d..9859cf0c3 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -45,25 +45,26 @@ def entry_point() -> None:
         prog="qiskit-ibm-runtime",
         description="Commands for the Qiskit IBM Runtime Python package",
     )
-    subparsers = parser.add_subparsers(
+    parser.add_subparsers(
         title="Commands",
         description="This package supports the following commands:",
         dest="script",
         required=True,
-    )
-    subparsers.add_parser(
+    ).add_parser(
         "save-account",
         description=(
             "An interactive command-line interface to save your Qiskit IBM "
-            "Runtime account locally. This script is interactive-only and takes "
-            "no arguments."
+            "Runtime account locally. This script is interactive-only."
         ),
         help="Interactive command-line interface to save your account locally.",
+    ).add_argument(
+        "--no-color", action="store_true", help="Hide ANSI escape codes in output"
     )
     args = parser.parse_args()
+    use_color = not args.no_color
     if args.script == "save-account":
         try:
-            SaveAccountCLI.main()
+            SaveAccountCLI(color=use_color).main()
         except KeyboardInterrupt:
             sys.exit()
 
@@ -73,26 +74,29 @@ class SaveAccountCLI:
     This class contains the save-account command and helper functions.
     """
 
-    @classmethod
-    def main(cls) -> None:
+    def __init__(self, color: bool):
+        self.color = color
+        self.fmt = Formatter(color=color)
+
+    def main(self) -> None:
         """
         A CLI that guides users through getting their account information and
         saving it to disk.
         """
-        print(Format.box(["Qiskit IBM Runtime save account"]))
-        channel = cls.get_channel()
-        token = cls.get_token(channel)
+        print(self.fmt.box(["Qiskit IBM Runtime save account"]))
+        channel = self.get_channel()
+        token = self.get_token(channel)
         print("Verifying, this might take few seconds...")
         try:
             service = QiskitRuntimeService(channel=channel, token=token)
         except (ApiException, IBMNotAuthorizedError, RequestsApiError) as err:
             print(
-                Format.red(Format.bold("\nError while authorizing with your token\n"))
-                + Format.red(err.message)
+                self.fmt.red(self.fmt.bold("\nError while authorizing with your token\n"))
+                + self.fmt.red(err.message or "")
             )
             sys.exit(1)
-        instance = cls.get_instance(service)
-        cls.save_to_disk(
+        instance = self.get_instance(service)
+        self.save_to_disk(
             {
                 "channel": channel,
                 "token": token,
@@ -100,28 +104,25 @@ def main(cls) -> None:
             }
         )
 
-    @classmethod
-    def get_channel(cls) -> Channel:
+    def get_channel(self) -> Channel:
         """Ask user which channel to use"""
-        print(Format.bold("Select a channel"))
-        return UserInput.select_from_list(["ibm_quantum", "ibm_cloud"])
+        print(self.fmt.bold("Select a channel"))
+        return UserInput.select_from_list(["ibm_quantum", "ibm_cloud"], self.fmt)
 
-    @classmethod
-    def get_token(cls, channel: Channel) -> str:
+    def get_token(self, channel: Channel) -> str:
         """Ask user for their token"""
         token_url = {
             "ibm_quantum": "https://quantum.ibm.com",
             "ibm_cloud": "https://cloud.ibm.com/iam/apikeys",
         }[channel]
         print(
-            Format.bold("\nPaste your API token")
-            + f"\nYou can get this from {Format.cyan(token_url)}."
+            self.fmt.bold("\nPaste your API token")
+            + f"\nYou can get this from {self.fmt.cyan(token_url)}."
             + "\nFor security, you might not see any feedback when typing."
         )
         return UserInput.token()
 
-    @classmethod
-    def get_instance(cls, service: QiskitRuntimeService) -> str:
+    def get_instance(self, service: QiskitRuntimeService) -> str:
         """
         Ask user which instance to use, or select automatically if only one
         is available.
@@ -129,13 +130,12 @@ def get_instance(cls, service: QiskitRuntimeService) -> str:
         instances = service.instances()
         if len(instances) == 1:
             instance = instances[0]
-            print(f"Using instance {Format.greenbold(instance)}")
+            print(f"Using instance {self.fmt.greenbold(instance)}")
             return instance
-        print(Format.bold("\nSelect a default instance"))
-        return UserInput.select_from_list(instances)
+        print(self.fmt.bold("\nSelect a default instance"))
+        return UserInput.select_from_list(instances, self.fmt)
 
-    @classmethod
-    def save_to_disk(cls, account: dict) -> None:
+    def save_to_disk(self, account: dict) -> None:
         """
         Save account details to disk, confirming if they'd like to overwrite if
         one exists already. Display a warning that token is stored in plain
@@ -154,14 +154,17 @@ def save_to_disk(cls, account: dict) -> None:
                 print("Account not saved.")
                 return
 
-        print(f"Account saved to {Format.greenbold(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)}")
-        print(Format.box(
-            [
-                "⚠️ Warning: your token is saved to disk in plain text.",
-                "If on a shared computer, make sure to revoke your token",
-                "by regenerating it in your account settings when finished.",
-            ]
-        ))
+        print(f"Account saved to {self.fmt.greenbold(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)}")
+        print(
+            self.fmt.box(
+                [
+                    "⚠️ Warning: your token is saved to disk in plain text.",
+                    "If on a shared computer, make sure to revoke your token",
+                    "by regenerating it in your account settings when finished.",
+                ]
+            )
+        )
+
 
 class UserInput:
     """
@@ -190,7 +193,7 @@ def token() -> str:
                 return token
 
     @staticmethod
-    def select_from_list(options: List[T]) -> T:
+    def select_from_list(options: List[T], formatter: Formatter) -> T:
         """
         Prompt user to select from a list of options by entering a number.
         """
@@ -204,42 +207,55 @@ def select_from_list(options: List[T]) -> T:
             and int(response) in range(1, len(options) + 1),
         )
         choice = options[int(response) - 1]
-        print(f"Selected {Format.greenbold(str(choice))}")
+        print(f"Selected {formatter.greenbold(str(choice))}")
         return choice
 
 
-class Format:
+class Formatter:
     """Format using terminal escape codes"""
 
     # pylint: disable=missing-function-docstring
+    #
+    def __init__(self, color: bool):
+        self.color = color
 
     @staticmethod
-    def box(lines: List[str]) -> str:
+    def _skip_if_no_color(method):
+        """Decorator to skip the method if self.color == False"""
+
+        def new_method(self, s: str) -> str:
+            if not self.color:
+                return s
+            return method(self, s)
+
+        return new_method
+
+    def box(self, lines: List[str]) -> str:
         """Print lines in a box using Unicode box-drawing characters"""
         width = max(len(line) for line in lines)
         box_lines = [
             "╭─" + "─" * width + "─╮",
-            *(f"│ {Format.bold(line.ljust(width))} │" for line in lines),
+            *(f"│ {self.bold(line.ljust(width))} │" for line in lines),
             "╰─" + "─" * width + "─╯",
         ]
         return "\n".join(box_lines)
 
-    @staticmethod
-    def bold(s: str) -> str:
+    @_skip_if_no_color
+    def bold(self, s: str) -> str:
         return f"\033[1m{s}\033[0m"
 
-    @staticmethod
-    def green(s: str) -> str:
+    @_skip_if_no_color
+    def green(self, s: str) -> str:
         return f"\033[32m{s}\033[0m"
 
-    @staticmethod
-    def red(s: str) -> str:
+    @_skip_if_no_color
+    def red(self, s: str) -> str:
         return f"\033[31m{s}\033[0m"
 
-    @staticmethod
-    def cyan(s: str) -> str:
+    @_skip_if_no_color
+    def cyan(self, s: str) -> str:
         return f"\033[36m{s}\033[0m"
 
-    @staticmethod
-    def greenbold(s: str) -> str:
-        return cls.green(cls.bold(s))
+    @_skip_if_no_color
+    def greenbold(self, s: str) -> str:
+        return self.green(self.bold(s))
diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index 99a1ed3e6..d4ff89101 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -16,7 +16,7 @@
 from unittest.mock import patch
 from textwrap import dedent
 
-from qiskit_ibm_runtime._cli import SaveAccountCLI, UserInput
+from qiskit_ibm_runtime._cli import SaveAccountCLI, UserInput, Formatter
 
 from qiskit_ibm_runtime.accounts.account import IBM_CLOUD_API_URL, IBM_QUANTUM_API_URL
 from ..ibm_test_case import IBMTestCase
@@ -55,7 +55,7 @@ def test_select_from_list(self):
         @patch("builtins.input", mockio.mock_input)
         @patch("builtins.print", mockio.mock_print)
         def run_test():
-            choice = UserInput.select_from_list(["a", "b", "c", "d"])
+            choice = UserInput.select_from_list(["a", "b", "c", "d"], Formatter(color=False))
             self.assertEqual(choice, "c")
 
         run_test()
@@ -80,8 +80,10 @@ def run_test():
             Enter a number 1-4 and press enter:•
             Did not understand input, trying again... (or type 'q' to quit)
             Enter a number 1-4 and press enter:•
-            Selected \033[32m\033[1mc\033[0m\033[0m
-            """.replace("•", " ")
+            Selected c
+            """.replace(
+                    "•", " "
+                )
             ),
         )
 
@@ -137,7 +139,7 @@ def instances(self):
         @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
         @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
-            SaveAccountCLI.main()
+            SaveAccountCLI(color=True).main()
 
         run_cli()
         self.assertEqual(mockio.inputs, [])
@@ -182,7 +184,7 @@ def instances(self):
         @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
         @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
-            SaveAccountCLI.main()
+            SaveAccountCLI(color=True).main()
 
         run_cli()
         self.assertEqual(mockio.inputs, [])

From 89d4374bbc8e7d27380cb95d88278f9dcafbd4a9 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Mon, 16 Dec 2024 15:17:41 +0000
Subject: [PATCH 19/30] Move `Formatter` class

Needs to be above SaveAccountCLI so it's defined when we use it in type
hints. I did this in a separate commit to make the changes clearer.
---
 qiskit_ibm_runtime/_cli.py | 100 ++++++++++++++++++-------------------
 1 file changed, 50 insertions(+), 50 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 9859cf0c3..b096928dc 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -69,6 +69,56 @@ def entry_point() -> None:
             sys.exit()
 
 
+class Formatter:
+    """Format using terminal escape codes"""
+
+    # pylint: disable=missing-function-docstring
+    #
+    def __init__(self, color: bool):
+        self.color = color
+
+    @staticmethod
+    def _skip_if_no_color(method):
+        """Decorator to skip the method if self.color == False"""
+
+        def new_method(self, s: str) -> str:
+            if not self.color:
+                return s
+            return method(self, s)
+
+        return new_method
+
+    def box(self, lines: List[str]) -> str:
+        """Print lines in a box using Unicode box-drawing characters"""
+        width = max(len(line) for line in lines)
+        box_lines = [
+            "╭─" + "─" * width + "─╮",
+            *(f"│ {self.bold(line.ljust(width))} │" for line in lines),
+            "╰─" + "─" * width + "─╯",
+        ]
+        return "\n".join(box_lines)
+
+    @_skip_if_no_color
+    def bold(self, s: str) -> str:
+        return f"\033[1m{s}\033[0m"
+
+    @_skip_if_no_color
+    def green(self, s: str) -> str:
+        return f"\033[32m{s}\033[0m"
+
+    @_skip_if_no_color
+    def red(self, s: str) -> str:
+        return f"\033[31m{s}\033[0m"
+
+    @_skip_if_no_color
+    def cyan(self, s: str) -> str:
+        return f"\033[36m{s}\033[0m"
+
+    @_skip_if_no_color
+    def greenbold(self, s: str) -> str:
+        return self.green(self.bold(s))
+
+
 class SaveAccountCLI:
     """
     This class contains the save-account command and helper functions.
@@ -209,53 +259,3 @@ def select_from_list(options: List[T], formatter: Formatter) -> T:
         choice = options[int(response) - 1]
         print(f"Selected {formatter.greenbold(str(choice))}")
         return choice
-
-
-class Formatter:
-    """Format using terminal escape codes"""
-
-    # pylint: disable=missing-function-docstring
-    #
-    def __init__(self, color: bool):
-        self.color = color
-
-    @staticmethod
-    def _skip_if_no_color(method):
-        """Decorator to skip the method if self.color == False"""
-
-        def new_method(self, s: str) -> str:
-            if not self.color:
-                return s
-            return method(self, s)
-
-        return new_method
-
-    def box(self, lines: List[str]) -> str:
-        """Print lines in a box using Unicode box-drawing characters"""
-        width = max(len(line) for line in lines)
-        box_lines = [
-            "╭─" + "─" * width + "─╮",
-            *(f"│ {self.bold(line.ljust(width))} │" for line in lines),
-            "╰─" + "─" * width + "─╯",
-        ]
-        return "\n".join(box_lines)
-
-    @_skip_if_no_color
-    def bold(self, s: str) -> str:
-        return f"\033[1m{s}\033[0m"
-
-    @_skip_if_no_color
-    def green(self, s: str) -> str:
-        return f"\033[32m{s}\033[0m"
-
-    @_skip_if_no_color
-    def red(self, s: str) -> str:
-        return f"\033[31m{s}\033[0m"
-
-    @_skip_if_no_color
-    def cyan(self, s: str) -> str:
-        return f"\033[36m{s}\033[0m"
-
-    @_skip_if_no_color
-    def greenbold(self, s: str) -> str:
-        return self.green(self.bold(s))

From 9f35940ed1af68ccfdd9a84b625ae9d28ca06c0d Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Tue, 17 Dec 2024 14:00:37 +0000
Subject: [PATCH 20/30] Add docstring

---
 qiskit_ibm_runtime/_cli.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index b096928dc..01a5c1974 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -237,6 +237,7 @@ def input(message: str, is_valid: Callable[[str], bool]) -> str:
 
     @staticmethod
     def token() -> str:
+        """Ask for API token, prompting again if empty"""
         while True:
             token = getpass("Token: ").strip()
             if token != "":

From 7c8302a2636ad3f6692211a81a9d2b0a1794c7bc Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Tue, 17 Dec 2024 17:52:50 +0000
Subject: [PATCH 21/30] Add type hints

---
 qiskit_ibm_runtime/_cli.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 01a5c1974..96a714b22 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -78,10 +78,12 @@ def __init__(self, color: bool):
         self.color = color
 
     @staticmethod
-    def _skip_if_no_color(method):
+    def _skip_if_no_color(
+        method: Callable[["Formatter", str], str]
+    ) -> Callable[["Formatter", str], str]:
         """Decorator to skip the method if self.color == False"""
 
-        def new_method(self, s: str) -> str:
+        def new_method(self: "Formatter", s: str) -> str:
             if not self.color:
                 return s
             return method(self, s)

From 7e76273c9992191c1173d1af1315c83c382bebbc Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 09:30:48 +0000
Subject: [PATCH 22/30] One-line test docstrings

So we see the full docstring when tests fail
---
 test/unit/test_cli.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index d4ff89101..cb32e86c3 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -88,8 +88,8 @@ def run_test():
         )
 
     def test_cli_multiple_instances_saved_account(self):
-        """Test a runthrough of the CLI when the user has access to many
-        instances and already has an account saved
+        """
+        Full CLI: User has many instances and account saved.
         """
         token = "Password123"
         instances = ["my/instance/1", "my/instance/2", "my/instance/3"]
@@ -148,8 +148,8 @@ def run_cli():
         self.assertEqual(written_output.strip(), expected_saved_account.strip())
 
     def test_cli_one_instance_no_saved_account(self):
-        """Test a runthrough of the CLI when the user only has access to one
-        instance and has no account saved.
+        """
+        Full CLI: user only has one instance and no account saved.
         """
         token = "QJjjbOxSfzZiskMZiyty"
         instance = "my/only/instance"

From 635a1677bd3947f024d7b7660408a02a1c1db7b1 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 12:21:54 +0000
Subject: [PATCH 23/30] Apply suggestions from code review

Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com>
---
 qiskit_ibm_runtime/_cli.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 96a714b22..88ca14fae 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -74,7 +74,7 @@ class Formatter:
 
     # pylint: disable=missing-function-docstring
     #
-    def __init__(self, color: bool):
+    def __init__(self, *, color: bool) -> None:
         self.color = color
 
     @staticmethod
@@ -126,8 +126,7 @@ class SaveAccountCLI:
     This class contains the save-account command and helper functions.
     """
 
-    def __init__(self, color: bool):
-        self.color = color
+    def __init__(self, *, color: bool) -> None:
         self.fmt = Formatter(color=color)
 
     def main(self) -> None:

From a3f3f4e7adba4f06135d37818952adf956203262 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 12:18:42 +0000
Subject: [PATCH 24/30] Simplify formatter

Co-authored-by: Eric Arellano <14852634+Eric-Arellano@users.noreply.github.com>
---
 qiskit_ibm_runtime/_cli.py | 66 ++++++++++++++------------------------
 1 file changed, 24 insertions(+), 42 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 88ca14fae..1d62323e3 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -77,48 +77,29 @@ class Formatter:
     def __init__(self, *, color: bool) -> None:
         self.color = color
 
-    @staticmethod
-    def _skip_if_no_color(
-        method: Callable[["Formatter", str], str]
-    ) -> Callable[["Formatter", str], str]:
-        """Decorator to skip the method if self.color == False"""
-
-        def new_method(self: "Formatter", s: str) -> str:
-            if not self.color:
-                return s
-            return method(self, s)
-
-        return new_method
-
     def box(self, lines: List[str]) -> str:
         """Print lines in a box using Unicode box-drawing characters"""
         width = max(len(line) for line in lines)
+        styled_lines = [self.text(line.ljust(width), "bold") for line in lines]
         box_lines = [
             "╭─" + "─" * width + "─╮",
-            *(f"│ {self.bold(line.ljust(width))} │" for line in lines),
+            *(f"│ {line} │" for line in styled_lines),
             "╰─" + "─" * width + "─╯",
         ]
         return "\n".join(box_lines)
 
-    @_skip_if_no_color
-    def bold(self, s: str) -> str:
-        return f"\033[1m{s}\033[0m"
-
-    @_skip_if_no_color
-    def green(self, s: str) -> str:
-        return f"\033[32m{s}\033[0m"
-
-    @_skip_if_no_color
-    def red(self, s: str) -> str:
-        return f"\033[31m{s}\033[0m"
-
-    @_skip_if_no_color
-    def cyan(self, s: str) -> str:
-        return f"\033[36m{s}\033[0m"
-
-    @_skip_if_no_color
-    def greenbold(self, s: str) -> str:
-        return self.green(self.bold(s))
+    def text(self, text: str, styles: str) -> str:
+        if not self.color:
+            return text
+        CODES = {
+            "bold": 1,
+            "green": 32,
+            "red": 31,
+            "cyan": 36,
+        }
+        ansi_start = "".join([f"\033[{CODES[style]}m" for style in styles.split(" ")])
+        ansi_end = "\033[0m"
+        return f"{ansi_start}{text}{ansi_end}"
 
 
 class SaveAccountCLI:
@@ -142,8 +123,8 @@ def main(self) -> None:
             service = QiskitRuntimeService(channel=channel, token=token)
         except (ApiException, IBMNotAuthorizedError, RequestsApiError) as err:
             print(
-                self.fmt.red(self.fmt.bold("\nError while authorizing with your token\n"))
-                + self.fmt.red(err.message or "")
+                self.fmt.text("\nError while authorizing with your token\n", "red bold")
+                + self.fmt.text(err.message or "", "red")
             )
             sys.exit(1)
         instance = self.get_instance(service)
@@ -157,7 +138,7 @@ def main(self) -> None:
 
     def get_channel(self) -> Channel:
         """Ask user which channel to use"""
-        print(self.fmt.bold("Select a channel"))
+        print(self.fmt.text("Select a channel", "bold"))
         return UserInput.select_from_list(["ibm_quantum", "ibm_cloud"], self.fmt)
 
     def get_token(self, channel: Channel) -> str:
@@ -166,9 +147,10 @@ def get_token(self, channel: Channel) -> str:
             "ibm_quantum": "https://quantum.ibm.com",
             "ibm_cloud": "https://cloud.ibm.com/iam/apikeys",
         }[channel]
+        styled_token_url = self.fmt.text(token_url, "cyan")
         print(
-            self.fmt.bold("\nPaste your API token")
-            + f"\nYou can get this from {self.fmt.cyan(token_url)}."
+            self.fmt.text("\nPaste your API token", "bold")
+            + f"\nYou can get this from {styled_token_url}."
             + "\nFor security, you might not see any feedback when typing."
         )
         return UserInput.token()
@@ -181,9 +163,9 @@ def get_instance(self, service: QiskitRuntimeService) -> str:
         instances = service.instances()
         if len(instances) == 1:
             instance = instances[0]
-            print(f"Using instance {self.fmt.greenbold(instance)}")
+            print(f"Using instance " + self.fmt.text(instance, "green bold"))
             return instance
-        print(self.fmt.bold("\nSelect a default instance"))
+        print(self.fmt.text("\nSelect a default instance", "bold"))
         return UserInput.select_from_list(instances, self.fmt)
 
     def save_to_disk(self, account: dict) -> None:
@@ -205,7 +187,7 @@ def save_to_disk(self, account: dict) -> None:
                 print("Account not saved.")
                 return
 
-        print(f"Account saved to {self.fmt.greenbold(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)}")
+        print(f"Account saved to " + self.fmt.text(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, "green bold"))
         print(
             self.fmt.box(
                 [
@@ -259,5 +241,5 @@ def select_from_list(options: List[T], formatter: Formatter) -> T:
             and int(response) in range(1, len(options) + 1),
         )
         choice = options[int(response) - 1]
-        print(f"Selected {formatter.greenbold(str(choice))}")
+        print(f"Selected " + formatter.text(str(choice), "green bold"))
         return choice

From c3ba41a1651464aec28a8fe4eec96f89d5c3285f Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 12:33:33 +0000
Subject: [PATCH 25/30] Incorporate review feedback

---
 qiskit_ibm_runtime/_cli.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 1d62323e3..729490830 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -47,7 +47,7 @@ def entry_point() -> None:
     )
     parser.add_subparsers(
         title="Commands",
-        description="This package supports the following commands:",
+        description="This package supports the following command:",
         dest="script",
         required=True,
     ).add_parser(
@@ -124,7 +124,8 @@ def main(self) -> None:
         except (ApiException, IBMNotAuthorizedError, RequestsApiError) as err:
             print(
                 self.fmt.text("\nError while authorizing with your token\n", "red bold")
-                + self.fmt.text(err.message or "", "red")
+                + self.fmt.text(err.message or "", "red"),
+                file=sys.stderr,
             )
             sys.exit(1)
         instance = self.get_instance(service)
@@ -184,7 +185,7 @@ def save_to_disk(self, account: dict) -> None:
             if response.strip().lower() in ["y", "yes"]:
                 AccountManager.save(**account, overwrite=True)
             else:
-                print("Account not saved.")
+                print("Account not saved.", file=sys.stderr)
                 return
 
         print(f"Account saved to " + self.fmt.text(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, "green bold"))
@@ -195,7 +196,8 @@ def save_to_disk(self, account: dict) -> None:
                     "If on a shared computer, make sure to revoke your token",
                     "by regenerating it in your account settings when finished.",
                 ]
-            )
+            ),
+            file=sys.stderr,
         )
 
 

From b5f033fb94708654a70fac3ca4ce2ebbe14a27a6 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 12:35:27 +0000
Subject: [PATCH 26/30] Fix tests

---
 test/unit/test_cli.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index cb32e86c3..9a60d88a5 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -36,7 +36,7 @@ def mock_input(self, *args, **_kwargs):
             self.mock_print(args[0])
         return self.inputs.pop(0)
 
-    def mock_print(self, *args):
+    def mock_print(self, *args, **_kwargs):
         self.output += " ".join(args) + "\n"
 
 

From 2ef0d97ad4310e46149af5cd3eb7303060b345a5 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 12:42:32 +0000
Subject: [PATCH 27/30] lint

---
 qiskit_ibm_runtime/_cli.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/qiskit_ibm_runtime/_cli.py b/qiskit_ibm_runtime/_cli.py
index 729490830..71ba7c75a 100644
--- a/qiskit_ibm_runtime/_cli.py
+++ b/qiskit_ibm_runtime/_cli.py
@@ -91,13 +91,13 @@ def box(self, lines: List[str]) -> str:
     def text(self, text: str, styles: str) -> str:
         if not self.color:
             return text
-        CODES = {
+        codes = {
             "bold": 1,
             "green": 32,
             "red": 31,
             "cyan": 36,
         }
-        ansi_start = "".join([f"\033[{CODES[style]}m" for style in styles.split(" ")])
+        ansi_start = "".join([f"\033[{codes[style]}m" for style in styles.split(" ")])
         ansi_end = "\033[0m"
         return f"{ansi_start}{text}{ansi_end}"
 
@@ -164,7 +164,7 @@ def get_instance(self, service: QiskitRuntimeService) -> str:
         instances = service.instances()
         if len(instances) == 1:
             instance = instances[0]
-            print(f"Using instance " + self.fmt.text(instance, "green bold"))
+            print("Using instance " + self.fmt.text(instance, "green bold"))
             return instance
         print(self.fmt.text("\nSelect a default instance", "bold"))
         return UserInput.select_from_list(instances, self.fmt)
@@ -188,7 +188,7 @@ def save_to_disk(self, account: dict) -> None:
                 print("Account not saved.", file=sys.stderr)
                 return
 
-        print(f"Account saved to " + self.fmt.text(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, "green bold"))
+        print("Account saved to " + self.fmt.text(_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, "green bold"))
         print(
             self.fmt.box(
                 [
@@ -243,5 +243,5 @@ def select_from_list(options: List[T], formatter: Formatter) -> T:
             and int(response) in range(1, len(options) + 1),
         )
         choice = options[int(response) - 1]
-        print(f"Selected " + formatter.text(str(choice), "green bold"))
+        print("Selected " + formatter.text(str(choice), "green bold"))
         return choice

From 19dca4ab662b7df2d4e8e22aa5c10b1544f6a16f Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 15:34:41 +0000
Subject: [PATCH 28/30] Test commit to debug CI

---
 test/unit/test_cli.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index 9a60d88a5..cc66c8551 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -189,5 +189,6 @@ def run_cli():
         run_cli()
         self.assertEqual(mockio.inputs, [])
 
+        print(mock_open().write.mock_calls)
         written_output = "".join(call.args[0] for call in mock_open().write.mock_calls)
         self.assertEqual(written_output.strip(), expected_saved_account.strip())

From ae13cf2c079624f85fce0da39639809b60e6d98b Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 16:03:59 +0000
Subject: [PATCH 29/30] Attempt fix for CI

---
 test/unit/test_cli.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index cc66c8551..4943c556c 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -136,6 +136,7 @@ def instances(self):
         @patch("builtins.input", mockio.mock_input)
         @patch("builtins.open", mock_open)
         @patch("builtins.print", mockio.mock_print)
+        @patch("os.path.isfile", lambda *args: True)
         @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
         @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
@@ -181,6 +182,7 @@ def instances(self):
         @patch("builtins.input", mockio.mock_input)
         @patch("builtins.open", mock_open)
         @patch("builtins.print", mockio.mock_print)
+        @patch("os.path.isfile", lambda *args: False)
         @patch("qiskit_ibm_runtime._cli.getpass", mockio.mock_input)
         @patch("qiskit_ibm_runtime._cli.QiskitRuntimeService", MockRuntimeService)
         def run_cli():
@@ -191,4 +193,5 @@ def run_cli():
 
         print(mock_open().write.mock_calls)
         written_output = "".join(call.args[0] for call in mock_open().write.mock_calls)
-        self.assertEqual(written_output.strip(), expected_saved_account.strip())
+        # The extra "{}" is runtime ensuring the file exists
+        self.assertEqual(written_output.strip(), "{}" + expected_saved_account.strip())

From 4405c5aa4930ca7f78b2e1da03c5d87284ea0944 Mon Sep 17 00:00:00 2001
From: Frank Harkins <frankharkins@hotmail.co.uk>
Date: Wed, 18 Dec 2024 16:13:15 +0000
Subject: [PATCH 30/30] Revert "Test commit to debug CI"

This reverts commit 19dca4ab662b7df2d4e8e22aa5c10b1544f6a16f.
---
 test/unit/test_cli.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/test/unit/test_cli.py b/test/unit/test_cli.py
index 4943c556c..46b495dfe 100644
--- a/test/unit/test_cli.py
+++ b/test/unit/test_cli.py
@@ -191,7 +191,6 @@ def run_cli():
         run_cli()
         self.assertEqual(mockio.inputs, [])
 
-        print(mock_open().write.mock_calls)
         written_output = "".join(call.args[0] for call in mock_open().write.mock_calls)
         # The extra "{}" is runtime ensuring the file exists
         self.assertEqual(written_output.strip(), "{}" + expected_saved_account.strip())