From 90103c4f46027539d5783831bd31a9c0691113e9 Mon Sep 17 00:00:00 2001 From: Frank Harkins 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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())