Repository: Qiskit/qiskit-ibm-runtime
Pull request #733 (2023-03-02 15:33:09)

feat: Add support for the qiskit-version-2 header format
Closes https://github.ibm.com/IBM-Q-Software/ntc/issues/2058
Closes https://github.com/Qiskit/qiskit-ibm-runtime/issues/685
<!--
⚠️ 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

This PR adds support for the `qiskit-version-2` header format. The new header formats includes proper versioning and the "caller" of a job. 

#### Sample header
`qiskit-version-2/qiskit-terra-0.23.0*,qiskit-aer-0.11.2/qiskit-terra-algorithms/random_client_header`



CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/Qiskit/qiskit-ibm-runtime?pullRequest=733) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/Qiskit/qiskit-ibm-runtime?pullRequest=733) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/Qiskit/qiskit-ibm-runtime?pullRequest=733) it.</sub>
coveralls: ## Pull Request Test Coverage Report for [Build 4315770573](https://coveralls.io/builds/57476935)

* **-2** of **17**   **(88.24%)**  changed or added relevant lines in **1** file are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.03%**) to **66.808%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [qiskit_ibm_runtime/api/session.py](https://coveralls.io/builds/57476935/source?filename=qiskit_ibm_runtime%2Fapi%2Fsession.py#L80) | 15 | 17 | 88.24%
<!-- | **Total:** | **15** | **17** | **88.24%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/57476935/badge)](https://coveralls.io/builds/57476935) |
| :-- | --: |
| Change from base [Build 4305578161](https://coveralls.io/builds/57429054): |  0.03% |
| Covered Lines: | 3305 |
| Relevant Lines: | 4947 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/qiskit_ibm_runtime/api/session.py b/qiskit_ibm_runtime/api/session.py
index c9650d4e..2aa3edcd 100644
--- a/qiskit_ibm_runtime/api/session.py
+++ b/qiskit_ibm_runtime/api/session.py
@@ -12,10 +12,13 @@
 
 """Session customized for IBM Quantum access."""
 
+import inspect
 import os
 import re
 import logging
+import sys
 from typing import Dict, Optional, Any, Tuple, Union
+from pathlib import PurePath
 import pkg_resources
 
 from requests import Session, RequestException, Response
@@ -51,20 +54,31 @@ RE_BACKENDS_ENDPOINT = re.compile(r"^(.*/backends/)([^/}]{2,})(.*)$", re.IGNOREC
 
 def _get_client_header() -> str:
     """Return the client version."""
-    try:
-        client_header = "qiskit/" + pkg_resources.get_distribution("qiskit").version
-        return client_header
-    except Exception:  # pylint: disable=broad-except
-        pass
-
-    qiskit_pkgs = ["qiskit-terra", "qiskit-aer", "qiskit-ignis", "qiskit-aqua"]
-    pkg_versions = {"qiskit-ibm-runtime": ibm_runtime_version}
+
+    qiskit_pkgs = [
+        "qiskit_terra",
+        "qiskit_aer",
+        "qiskit_experiments",
+        "qiskit_nature",
+        "qiskit_machine_learning",
+        "qiskit_optimization",
+        "qiskit_finance",
+    ]
+
+    pkg_versions = {"qiskit_ibm_runtime": f"qiskit_ibm_runtime-{ibm_runtime_version}"}
     for pkg_name in qiskit_pkgs:
         try:
-            pkg_versions[pkg_name] = pkg_resources.get_distribution(pkg_name).version
+            version_info = (
+                f"{pkg_name}-{pkg_resources.get_distribution(pkg_name).version}"
+            )
+
+            if pkg_name in sys.modules:
+                version_info += "*"
+
+            pkg_versions[pkg_name] = version_info
         except Exception:  # pylint: disable=broad-except
             pass
-    return ",".join(pkg_versions.keys()) + "/" + ",".join(pkg_versions.values())
+    return f"qiskit-version-2/{','.join(pkg_versions.values())}"
 
 
 CLIENT_APPLICATION = _get_client_header()
@@ -166,7 +180,7 @@ class RetrySession(Session):
         super().__init__()
 
         self.base_url = base_url
-
+        self.custom_header: Optional[str] = None
         self._initialize_retry(retries_total, retries_connect, backoff_factor)
         self._initialize_session_parameters(verify, proxies or {}, auth)
         self._timeout = timeout
@@ -210,17 +224,9 @@ class RetrySession(Session):
             proxies: Proxy URLs mapped by protocol.
             auth: Authentication handler.
         """
-        client_app_header = CLIENT_APPLICATION
-
-        # Append custom header to the end if specified
-        custom_header = os.getenv(CUSTOM_HEADER_ENV_VAR) or os.getenv(
+        self.custom_header = os.getenv(CUSTOM_HEADER_ENV_VAR) or os.getenv(
             QE_PROVIDER_HEADER_ENV_VAR
         )
-        if custom_header:
-            client_app_header += "/" + custom_header
-
-        self.headers.update({"X-Qx-Client-Application": client_app_header})
-
         self.auth = auth
         self.proxies = proxies or {}
         self.verify = verify
@@ -261,9 +267,38 @@ class RetrySession(Session):
         if not self.proxies and "timeout" not in kwargs:
             kwargs.update({"timeout": self._timeout})
 
-        headers = self.headers.copy()
+        headers = self.headers.copy()  # type: ignore
         headers.update(kwargs.pop("headers", {}))
 
+        # Set default caller
+        headers.update({"X-Qx-Client-Application": f"{CLIENT_APPLICATION}/qiskit"})
+
+        # Use PurePath in order to support arbitrary path formats
+        callers = {PurePath("qiskit/"), "qiskit_"}
+
+        stack = inspect.stack()
+        stack.reverse()
+
+        found_caller = False
+        for frame in stack:
+            frame_path = str(PurePath(frame.filename))
+            for caller in callers:
+                if str(caller) in frame_path:
+                    caller_str = str(caller) + frame_path.split(str(caller), 1)[-1]
+                    sanitized_caller_str = caller_str.replace("/", "~")
+                    headers.update(
+                        {
+                            "X-Qx-Client-Application": f"{CLIENT_APPLICATION}/{sanitized_caller_str}"
+                        }
+                    )
+                    found_caller = True
+                    break  # break out of the inner loop
+            if found_caller:
+                break  # break out of the outer loop
+
+        self.headers = headers
+        self._set_custom_header()
+
         try:
             self._log_request_info(final_url, method, kwargs)
             response = super().request(method, final_url, headers=headers, **kwargs)
@@ -373,6 +408,16 @@ class RetrySession(Session):
 
         return True
 
+    def _set_custom_header(self) -> None:
+        """Set custom header."""
+        headers = self.headers.copy()  # type: ignore
+        if self.custom_header:
+            current = headers["X-Qx-Client-Application"]
+            headers.update(
+                {"X-Qx-Client-Application": f"{current}/{self.custom_header}"}
+            )
+            self.headers = headers
+
     def __getstate__(self) -> Dict:
         """Overwrite Session's getstate to include all attributes."""
         state = super().__getstate__()  # type: ignore


Test patch:
diff --git a/test/unit/test_runtime_client.py b/test/unit/test_runtime_client.py
new file mode 100644
index 00000000..d7423edb
--- /dev/null
+++ b/test/unit/test_runtime_client.py
@@ -0,0 +1,97 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2021.
+#
+# 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 for the RuntimeClient class."""
+
+from qiskit_ibm_runtime.api.client_parameters import ClientParameters
+from qiskit_ibm_runtime.api.clients import RuntimeClient
+from qiskit_ibm_runtime.api.exceptions import RequestsApiError
+
+from .mock.http_server import SimpleServer, ClientErrorHandler
+from ..ibm_test_case import IBMTestCase
+from ..account import custom_envs, no_envs
+
+
+class TestAccountClient(IBMTestCase):
+    """Tests for RuntimeClient."""
+
+    def setUp(self):
+        """Initial test setup."""
+        super().setUp()
+        self.fake_server = None
+
+    def tearDown(self) -> None:
+        """Test level tear down."""
+        super().tearDown()
+        if self.fake_server:
+            self.fake_server.stop()
+
+    def _get_client(self):
+        """Helper for instantiating an RuntimeClient."""
+        # pylint: disable=no-value-for-parameter
+        params = ClientParameters(
+            channel="ibm_quantum",
+            url=SimpleServer.URL,
+            token="foo",
+            instance="h/g/p",
+        )
+        return RuntimeClient(params)
+
+    def test_client_error(self):
+        """Test client error."""
+        client = self._get_client()
+        self.fake_server = SimpleServer(handler_class=ClientErrorHandler)
+        self.fake_server.start()
+        # client.account_api.session.base_url = SimpleServer.URL
+
+        sub_tests = [
+            {"error": "Bad client input"},
+            {},
+            {"bad request": "Bad client input"},
+            "Bad client input",
+        ]
+
+        for err_resp in sub_tests:
+            with self.subTest(response=err_resp):
+                self.fake_server.set_error_response(err_resp)
+                with self.assertRaises(RequestsApiError) as err_cm:
+                    client.backend_status("ibmq_qasm_simulator")
+                if err_resp:
+                    self.assertIn("Bad client input", str(err_cm.exception))
+
+    def test_custom_client_app_header(self):
+        """Check custom client application header."""
+
+        custom_header = "batman"
+        with custom_envs(
+            {"QISKIT_IBM_RUNTIME_CUSTOM_CLIENT_APP_HEADER": custom_header}
+        ):
+            client = self._get_client()
+            client._session.headers.update(
+                {"X-Qx-Client-Application": "qiskit-version-2/qiskit"}
+            )
+            client._session._set_custom_header()
+            self.assertIn(
+                custom_header, client._session.headers["X-Qx-Client-Application"]
+            )
+
+        # Make sure the header is re-initialized
+        with no_envs(["QISKIT_IBM_RUNTIME_CUSTOM_CLIENT_APP_HEADER"]):
+            client = self._get_client()
+            client._session.headers.update(
+                {"X-Qx-Client-Application": "qiskit-version-2/qiskit"}
+            )
+            client._session.custom_header = None
+            client._session._set_custom_header()
+            self.assertNotIn(
+                custom_header, client._session.headers["X-Qx-Client-Application"]
+            )