Repository: Qiskit/qiskit-ibm-runtime (python)

Issue:
Make qiskit-ibm.json save path configurable
**What is the expected feature or enhancement?**

When using QiskitRuntimeService.save_account() the credentials are saved to $HOME/.qiskit/qiskit-ibm.json and that's hard-coded. When using IBM Quantum Lab the $HOME/.qiskit directory is not preserved, so it'd be nice to be able to specify or configure qiskit for a different location to save and read those credentials so they can be persisted in quantum lab. Otherwise I have to keep my apikey and CRN in a notebook cell, which isn't a huge problem but it's not ideal.

**Acceptance criteria**

Be able to save and load the account credentials to/from some other location.

Pull request:
Added support for storing the account in a specified file-name
<!--
⚠️ The pull request title should be short detailed and understandable for all.
⚠️ Also, please add a release note file using reno if the change needs to be
  documented in the release notes.
⚠️ If your pull request fixes an open issue, please link to the issue.

✅ I have added the tests to cover my changes.
✅ I have updated the documentation accordingly.
✅ I have read the CONTRIBUTING document.
-->

### Summary
Enable storing account details in a file specified by the user other than in `~/.qiskit/qiskit-ibm.json`.


### Details and comments
Fixes #614.
The default remains as before.



Patch:
diff --git a/qiskit_ibm_runtime/accounts/management.py b/qiskit_ibm_runtime/accounts/management.py
index 6590f684..634497b8 100644
--- a/qiskit_ibm_runtime/accounts/management.py
+++ b/qiskit_ibm_runtime/accounts/management.py
@@ -43,16 +43,19 @@ class AccountManager:
         url: Optional[str] = None,
         instance: Optional[str] = None,
         channel: Optional[ChannelType] = None,
+        filename: Optional[str] = None,
         name: Optional[str] = _DEFAULT_ACCOUNT_NAME,
         proxies: Optional[ProxyConfiguration] = None,
         verify: Optional[bool] = None,
         overwrite: Optional[bool] = False,
     ) -> None:
         """Save account on disk."""
-        cls.migrate()
+        cls.migrate(filename=filename)
         name = name or cls._get_default_account_name(channel)
+        filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE
+        filename = os.path.expanduser(filename)
         return save_config(
-            filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE,
+            filename=filename,
             name=name,
             overwrite=overwrite,
             config=Account(
@@ -71,10 +74,13 @@ class AccountManager:
     def list(
         default: Optional[bool] = None,
         channel: Optional[ChannelType] = None,
+        filename: Optional[str] = None,
         name: Optional[str] = None,
     ) -> Dict[str, Account]:
-        """List all accounts saved on disk."""
-        AccountManager.migrate()
+        """List all accounts in a given filename, or in the default account file."""
+        filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE
+        filename = os.path.expanduser(filename)
+        AccountManager.migrate(filename)
 
         def _matching_name(account_name: str) -> bool:
             return name is None or name == account_name
@@ -101,7 +107,7 @@ class AccountManager:
                 kv[0],
                 Account.from_saved_format(kv[1]),
             ),
-            read_config(filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE).items(),
+            read_config(filename=filename).items(),
         )
 
         # filter based on input parameters
@@ -120,11 +126,15 @@ class AccountManager:
 
     @classmethod
     def get(
-        cls, name: Optional[str] = None, channel: Optional[ChannelType] = None
+        cls,
+        filename: Optional[str] = None,
+        name: Optional[str] = None,
+        channel: Optional[ChannelType] = None,
     ) -> Optional[Account]:
         """Read account from disk.
 
         Args:
+            filename: Full path of the file from which to get the account.
             name: Account name. Takes precedence if `auth` is also specified.
             channel: Channel type.
 
@@ -134,11 +144,11 @@ class AccountManager:
         Raises:
             AccountNotFoundError: If the input value cannot be found on disk.
         """
-        cls.migrate()
+        filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE
+        filename = os.path.expanduser(filename)
+        cls.migrate(filename)
         if name:
-            saved_account = read_config(
-                filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, name=name
-            )
+            saved_account = read_config(filename=filename, name=name)
             if not saved_account:
                 raise AccountNotFoundError(
                     f"Account with the name {name} does not exist on disk."
@@ -152,14 +162,14 @@ class AccountManager:
 
         if channel:
             saved_account = read_config(
-                filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE,
+                filename=filename,
                 name=cls._get_default_account_name(channel=channel),
             )
             if saved_account is None:
                 raise AccountNotFoundError(f"No default {channel} account saved.")
             return Account.from_saved_format(saved_account)
 
-        all_config = read_config(filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)
+        all_config = read_config(filename=filename)
         for channel_type in _CHANNEL_TYPES:
             account_name = cls._get_default_account_name(channel=channel_type)
             if account_name in all_config:
@@ -197,28 +207,33 @@ class AccountManager:
     @classmethod
     def delete(
         cls,
+        filename: Optional[str] = None,
         name: Optional[str] = None,
         channel: Optional[ChannelType] = None,
     ) -> bool:
         """Delete account from disk."""
-        cls.migrate()
+        filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE
+        filename = os.path.expanduser(filename)
+        cls.migrate(filename=filename)
         name = name or cls._get_default_account_name(channel)
         return delete_config(
-            filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE,
+            filename=filename,
             name=name,
         )
 
     @classmethod
-    def migrate(cls) -> None:
+    def migrate(cls, filename: Optional[str] = None) -> None:
         """Migrate accounts on disk by removing `auth` and adding `channel`."""
-        data = read_config(filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE)
+        filename = filename if filename else _DEFAULT_ACCOUNT_CONFIG_JSON_FILE
+        filename = os.path.expanduser(filename)
+        data = read_config(filename=filename)
         for key, value in data.items():
             if key == _DEFAULT_ACCOUNT_NAME_CLOUD:
                 value.pop("auth", None)
                 value.update(channel="ibm_cloud")
-                delete_config(filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, name=key)
+                delete_config(filename=filename, name=key)
                 save_config(
-                    filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE,
+                    filename=filename,
                     name=_DEFAULT_ACCOUNT_NAME_IBM_CLOUD,
                     config=value,
                     overwrite=False,
@@ -226,9 +241,9 @@ class AccountManager:
             elif key == _DEFAULT_ACCOUNT_NAME_LEGACY:
                 value.pop("auth", None)
                 value.update(channel="ibm_quantum")
-                delete_config(filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE, name=key)
+                delete_config(filename=filename, name=key)
                 save_config(
-                    filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE,
+                    filename=filename,
                     name=_DEFAULT_ACCOUNT_NAME_IBM_QUANTUM,
                     config=value,
                     overwrite=False,
@@ -241,7 +256,7 @@ class AccountManager:
                         value.update(channel="ibm_quantum")
                     value.pop("auth", None)
                     save_config(
-                        filename=_DEFAULT_ACCOUNT_CONFIG_JSON_FILE,
+                        filename=filename,
                         name=key,
                         config=value,
                         overwrite=True,
diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py
index 9440c76e..d0de46f7 100644
--- a/qiskit_ibm_runtime/qiskit_runtime_service.py
+++ b/qiskit_ibm_runtime/qiskit_runtime_service.py
@@ -134,6 +134,7 @@ class QiskitRuntimeService(Provider):
         auth: Optional[AccountType] = None,
         token: Optional[str] = None,
         url: Optional[str] = None,
+        filename: Optional[str] = None,
         name: Optional[str] = None,
         instance: Optional[str] = None,
         proxies: Optional[dict] = None,
@@ -160,6 +161,8 @@ class QiskitRuntimeService(Provider):
             url: The API URL.
                 Defaults to https://cloud.ibm.com (ibm_cloud) or
                 https://auth.quantum-computing.ibm.com/api (ibm_quantum).
+            filename: Full path of the file where the account is created.
+                Default: _DEFAULT_ACCOUNT_CONFIG_JSON_FILE
             name: Name of the account to load.
             instance: The service instance to use.
                 For ``ibm_cloud`` runtime, this is the Cloud Resource Name (CRN) or the service name.
@@ -188,6 +191,7 @@ class QiskitRuntimeService(Provider):
             instance=instance,
             channel=channel,
             auth=auth,
+            filename=filename,
             name=name,
             proxies=ProxyConfiguration(**proxies) if proxies else None,
             verify=verify,
@@ -247,6 +251,7 @@ class QiskitRuntimeService(Provider):
         instance: Optional[str] = None,
         channel: Optional[ChannelType] = None,
         auth: Optional[AccountType] = None,
+        filename: Optional[str] = None,
         name: Optional[str] = None,
         proxies: Optional[ProxyConfiguration] = None,
         verify: Optional[bool] = None,
@@ -255,13 +260,22 @@ class QiskitRuntimeService(Provider):
         account = None
         verify_ = verify or True
         if name:
-            if any([auth, channel, token, url]):
-                logger.warning(
-                    "Loading account with name %s. Any input 'auth', "
-                    "'channel', 'token' or 'url' are ignored.",
-                    name,
-                )
-            account = AccountManager.get(name=name)
+            if filename:
+                if any([auth, channel, token, url]):
+                    logger.warning(
+                        "Loading account from file %s with name %s. Any input 'auth', "
+                        "'channel', 'token' or 'url' are ignored.",
+                        filename,
+                        name,
+                    )
+            else:
+                if any([auth, channel, token, url]):
+                    logger.warning(
+                        "Loading account with name %s. Any input 'auth', "
+                        "'channel', 'token' or 'url' are ignored.",
+                        name,
+                    )
+            account = AccountManager.get(filename=filename, name=name)
         elif auth or channel:
             if auth and auth not in ["legacy", "cloud"]:
                 raise ValueError("'auth' can only be 'cloud' or 'legacy'")
@@ -282,7 +296,9 @@ class QiskitRuntimeService(Provider):
                     logger.warning(
                         "Loading default %s account. Input 'url' is ignored.", channel
                     )
-                account = AccountManager.get(channel=channel)
+                account = AccountManager.get(
+                    filename=filename, name=name, channel=channel
+                )
         elif any([token, url]):
             # Let's not infer based on these attributes as they may change in the future.
             raise ValueError(
@@ -290,7 +306,7 @@ class QiskitRuntimeService(Provider):
             )
 
         if account is None:
-            account = AccountManager.get()
+            account = AccountManager.get(filename=filename)
 
         if instance:
             account.instance = instance
@@ -576,6 +592,7 @@ class QiskitRuntimeService(Provider):
 
     @staticmethod
     def delete_account(
+        filename: Optional[str] = None,
         name: Optional[str] = None,
         auth: Optional[str] = None,
         channel: Optional[ChannelType] = None,
@@ -583,6 +600,7 @@ class QiskitRuntimeService(Provider):
         """Delete a saved account from disk.
 
         Args:
+            filename: Name of file from which to delete the account.
             name: Name of the saved account to delete.
             auth: (DEPRECATED, use `channel` instead) Authentication type of the default
                 account to delete. Ignored if account name is provided.
@@ -597,7 +615,7 @@ class QiskitRuntimeService(Provider):
             QiskitRuntimeService._auth_warning()
             channel = channel or QiskitRuntimeService._get_channel_for_auth(auth=auth)
 
-        return AccountManager.delete(name=name, channel=channel)
+        return AccountManager.delete(filename=filename, name=name, channel=channel)
 
     @staticmethod
     def _get_channel_for_auth(auth: str) -> str:
@@ -613,6 +631,7 @@ class QiskitRuntimeService(Provider):
         instance: Optional[str] = None,
         channel: Optional[ChannelType] = None,
         auth: Optional[AccountType] = None,
+        filename: Optional[str] = None,
         name: Optional[str] = None,
         proxies: Optional[dict] = None,
         verify: Optional[bool] = None,
@@ -628,6 +647,7 @@ class QiskitRuntimeService(Provider):
             instance: The CRN (ibm_cloud) or hub/group/project (ibm_quantum).
             channel: Channel type. `ibm_cloud` or `ibm_quantum`.
             auth: (DEPRECATED, use `channel` instead) Authentication type. `cloud` or `legacy`.
+            filename: Full path of the file where the account is saved.
             name: Name of the account to save.
             proxies: Proxy configuration. Supported optional keys are
                 ``urls`` (a dictionary mapping protocol or protocol and host to the URL of the proxy,
@@ -646,6 +666,7 @@ class QiskitRuntimeService(Provider):
             url=url,
             instance=instance,
             channel=channel,
+            filename=filename,
             name=name,
             proxies=ProxyConfiguration(**proxies) if proxies else None,
             verify=verify,
@@ -657,6 +678,7 @@ class QiskitRuntimeService(Provider):
         default: Optional[bool] = None,
         auth: Optional[str] = None,
         channel: Optional[ChannelType] = None,
+        filename: Optional[str] = None,
         name: Optional[str] = None,
     ) -> dict:
         """List the accounts saved on disk.
@@ -666,6 +688,7 @@ class QiskitRuntimeService(Provider):
             auth: (DEPRECATED, use `channel` instead) If set, only accounts with the given
                 authentication type are returned.
             channel: Channel type. `ibm_cloud` or `ibm_quantum`.
+            filename: Name of file whose accounts are returned.
             name: If set, only accounts with the given name are returned.
 
         Returns:
@@ -681,7 +704,7 @@ class QiskitRuntimeService(Provider):
             map(
                 lambda kv: (kv[0], Account.to_saved_format(kv[1])),
                 AccountManager.list(
-                    default=default, channel=channel, name=name
+                    default=default, channel=channel, filename=filename, name=name
                 ).items(),
             ),
         )
diff --git a/releasenotes/notes/user-defined-filename-for-account-710cb32e812b3bd8.yaml b/releasenotes/notes/user-defined-filename-for-account-710cb32e812b3bd8.yaml
new file mode 100644
index 00000000..e3b61a69
--- /dev/null
+++ b/releasenotes/notes/user-defined-filename-for-account-710cb32e812b3bd8.yaml
@@ -0,0 +1,17 @@
+---
+upgrade:
+  - |
+    Allow the user to store account details in a file specified by the user in the parameter. 
+    ``filename``. The default remains `~/.qiskit/qiskit-ibm.json`. Example of usage: 
+    Ex::
+    
+        QiskitRuntimeService.save_account(channel="ibm_quantum",
+                                          filename="~/my_account_file.json",
+                                          name = "my_account",
+                                          token="my_token")
+        service = QiskitRuntimeService(channel="ibm_quantum", 
+                                       filename="~/my_account_file.json", 
+                                       name = "my_account",)
+    
+
+


Test patch:
diff --git a/test/unit/test_account.py b/test/unit/test_account.py
index 5d697a55..7b7b6779 100644
--- a/test/unit/test_account.py
+++ b/test/unit/test_account.py
@@ -80,6 +80,8 @@ _TEST_CLOUD_ACCOUNT = {
     },
 }
 
+_TEST_FILENAME = "/tmp/temp_qiskit_account.json"
+
 
 class TestAccount(IBMTestCase):
     """Tests for Account class."""
@@ -193,6 +195,25 @@ class TestAccountManager(IBMTestCase):
                 channel="ibm_cloud",
                 overwrite=False,
             )
+        AccountManager.save(
+            filename=_TEST_FILENAME,
+            name="conflict",
+            token=_TEST_IBM_CLOUD_ACCOUNT.token,
+            url=_TEST_IBM_CLOUD_ACCOUNT.url,
+            instance=_TEST_IBM_CLOUD_ACCOUNT.instance,
+            channel="ibm_cloud",
+            overwrite=True,
+        )
+        with self.assertRaises(AccountAlreadyExistsError):
+            AccountManager.save(
+                filename=_TEST_FILENAME,
+                name="conflict",
+                token=_TEST_IBM_CLOUD_ACCOUNT.token,
+                url=_TEST_IBM_CLOUD_ACCOUNT.url,
+                instance=_TEST_IBM_CLOUD_ACCOUNT.instance,
+                channel="ibm_cloud",
+                overwrite=False,
+            )
 
     # TODO remove test when removing auth parameter
     @temporary_account_config_file(
@@ -358,24 +379,47 @@ class TestAccountManager(IBMTestCase):
         # - account to save
         # - the name passed to AccountManager.save
         # - the name passed to AccountManager.get
+        user_filename = _TEST_FILENAME
         sub_tests = [
             # verify accounts can be saved and retrieved via custom names
-            (_TEST_IBM_QUANTUM_ACCOUNT, "acct-1", "acct-1"),
-            (_TEST_IBM_CLOUD_ACCOUNT, "acct-2", "acct-2"),
+            (_TEST_IBM_QUANTUM_ACCOUNT, None, "acct-1", "acct-1"),
+            (_TEST_IBM_CLOUD_ACCOUNT, None, "acct-2", "acct-2"),
+            # verify default account name handling for ibm_cloud accounts
+            (_TEST_IBM_CLOUD_ACCOUNT, None, None, _DEFAULT_ACCOUNT_NAME_IBM_CLOUD),
+            (_TEST_IBM_CLOUD_ACCOUNT, None, None, None),
+            # verify default account name handling for ibm_quantum accounts
+            (
+                _TEST_IBM_QUANTUM_ACCOUNT,
+                None,
+                None,
+                _DEFAULT_ACCOUNT_NAME_IBM_QUANTUM,
+            ),
+            # verify account override
+            (_TEST_IBM_QUANTUM_ACCOUNT, None, "acct", "acct"),
+            (_TEST_IBM_CLOUD_ACCOUNT, None, "acct", "acct"),
+            # same as above with filename
+            (_TEST_IBM_QUANTUM_ACCOUNT, user_filename, "acct-1", "acct-1"),
+            (_TEST_IBM_CLOUD_ACCOUNT, user_filename, "acct-2", "acct-2"),
             # verify default account name handling for ibm_cloud accounts
-            (_TEST_IBM_CLOUD_ACCOUNT, None, _DEFAULT_ACCOUNT_NAME_IBM_CLOUD),
-            (_TEST_IBM_CLOUD_ACCOUNT, None, None),
+            (
+                _TEST_IBM_CLOUD_ACCOUNT,
+                user_filename,
+                None,
+                _DEFAULT_ACCOUNT_NAME_IBM_CLOUD,
+            ),
+            (_TEST_IBM_CLOUD_ACCOUNT, user_filename, None, None),
             # verify default account name handling for ibm_quantum accounts
             (
                 _TEST_IBM_QUANTUM_ACCOUNT,
+                user_filename,
                 None,
                 _DEFAULT_ACCOUNT_NAME_IBM_QUANTUM,
             ),
             # verify account override
-            (_TEST_IBM_QUANTUM_ACCOUNT, "acct", "acct"),
-            (_TEST_IBM_CLOUD_ACCOUNT, "acct", "acct"),
+            (_TEST_IBM_QUANTUM_ACCOUNT, user_filename, "acct", "acct"),
+            (_TEST_IBM_CLOUD_ACCOUNT, user_filename, "acct", "acct"),
         ]
-        for account, name_save, name_get in sub_tests:
+        for account, file_name, name_save, name_get in sub_tests:
             with self.subTest(
                 f"for account type '{account.channel}' "
                 f"using `save(name={name_save})` and `get(name={name_get})`"
@@ -387,10 +431,13 @@ class TestAccountManager(IBMTestCase):
                     channel=account.channel,
                     proxies=account.proxies,
                     verify=account.verify,
+                    filename=file_name,
                     name=name_save,
                     overwrite=True,
                 )
-                self.assertEqual(account, AccountManager.get(name=name_get))
+                self.assertEqual(
+                    account, AccountManager.get(filename=file_name, name=name_get)
+                )
 
     @temporary_account_config_file(
         contents=json.dumps(
@@ -525,7 +572,7 @@ class TestAccountManager(IBMTestCase):
         with self.subTest("delete default ibm_cloud account"):
             self.assertTrue(AccountManager.delete())
 
-        self.assertTrue(len(AccountManager.list()) == 0)
+            self.assertTrue(len(AccountManager.list()) == 0)
 
     @temporary_account_config_file(
         contents={
@@ -549,6 +596,49 @@ class TestAccountManager(IBMTestCase):
 
         self.assertTrue(len(AccountManager.list()) == 0)
 
+    def test_delete_filename(self):
+        """Test delete accounts with filename parameter."""
+
+        filename = "~/account_to_delete.json"
+        name = "key1"
+        channel = "ibm_quantum"
+        AccountManager.save(
+            channel=channel, filename=filename, name=name, token="temp_token"
+        )
+        self.assertTrue(
+            AccountManager.delete(channel="ibm_quantum", filename=filename, name=name)
+        )
+        self.assertFalse(
+            AccountManager.delete(channel="ibm_quantum", filename=filename, name=name)
+        )
+
+        self.assertTrue(
+            len(AccountManager.list(channel="ibm_quantum", filename=filename)) == 0
+        )
+
+    def test_account_with_filename(self):
+        """Test saving an account to a given filename and retrieving it."""
+        user_filename = _TEST_FILENAME
+        account_name = "my_account"
+        dummy_token = "dummy_token"
+        AccountManager.save(
+            channel="ibm_quantum",
+            filename=user_filename,
+            name=account_name,
+            overwrite=True,
+            token=dummy_token,
+        )
+        account = AccountManager.get(
+            channel="ibm_quantum", filename=user_filename, name=account_name
+        )
+        self.assertEqual(account.token, dummy_token)
+
+    def tearDown(self) -> None:
+        """Test level tear down."""
+        super().tearDown()
+        if os.path.exists(_TEST_FILENAME):
+            os.remove(_TEST_FILENAME)
+
 
 MOCK_PROXY_CONFIG_DICT = {
     "urls": {"https": "127.0.0.1", "username_ntlm": "", "password_ntlm": ""}