Repository: Qiskit/qiskit-ibmq-provider (language: python)

Issue:
Add program visibility endpoint
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues to confirm this idea does not exist. -->

### What is the expected enhancement?

Add an endpoint for the user to be able to toggle the visibility of a `RuntimeProgram` like this:

```
provider.runtime.set_program_visibility('private')
provider.runtime.set_program_visibility('public')
```

which interacts with the `/programs/{id}/public` and `../private` API endpoints.


Discussion / hints:

Fix patch:
diff --git a/qiskit/providers/ibmq/api/clients/runtime.py b/qiskit/providers/ibmq/api/clients/runtime.py
index 2940c77b1..9e523746c 100644
--- a/qiskit/providers/ibmq/api/clients/runtime.py
+++ b/qiskit/providers/ibmq/api/clients/runtime.py
@@ -106,6 +106,20 @@ def program_get_data(self, program_id: str) -> Dict:
         """
         return self.api.program(program_id).get_data()
 
+    def set_program_visibility(self, program_id: str, public: bool) -> None:
+        """Sets a program's visibility.
+
+        Args:
+            program_id: Program ID.
+            public: If ``True``, make the program visible to all.
+                If ``False``, make the program visible to just your account.
+
+        """
+        if public:
+            self.api.program(program_id).make_public()
+        else:
+            self.api.program(program_id).make_private()
+
     def program_run(
             self,
             program_id: str,
diff --git a/qiskit/providers/ibmq/api/rest/runtime.py b/qiskit/providers/ibmq/api/rest/runtime.py
index 7213f320a..a892134d3 100644
--- a/qiskit/providers/ibmq/api/rest/runtime.py
+++ b/qiskit/providers/ibmq/api/rest/runtime.py
@@ -180,7 +180,9 @@ class Program(RestAdapterBase):
     URL_MAP = {
         'self': '',
         'data': '/data',
-        'run': '/jobs'
+        'run': '/jobs',
+        'private': '/private',
+        'public': '/public'
     }
 
     _executor = futures.ThreadPoolExecutor()
@@ -213,6 +215,16 @@ def get_data(self) -> Dict[str, Any]:
         url = self.get_url('data')
         return self.session.get(url).json()
 
+    def make_public(self) -> None:
+        """Sets a runtime program's visibility to public."""
+        url = self.get_url('public')
+        self.session.put(url)
+
+    def make_private(self) -> None:
+        """Sets a runtime program's visibility to private."""
+        url = self.get_url('private')
+        self.session.put(url)
+
     def delete(self) -> None:
         """Delete this program.
 
diff --git a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py
index de690c76d..5a0e23dca 100644
--- a/qiskit/providers/ibmq/runtime/ibm_runtime_service.py
+++ b/qiskit/providers/ibmq/runtime/ibm_runtime_service.py
@@ -190,7 +190,8 @@ def _to_program(self, response: Dict) -> RuntimeProgram:
                               max_execution_time=response.get('cost', 0),
                               creation_date=response.get('creationDate', ""),
                               version=response.get('version', "0"),
-                              backend_requirements=backend_req)
+                              backend_requirements=backend_req,
+                              is_public=response.get('isPublic', False))
 
     def run(
             self,
@@ -396,6 +397,25 @@ def delete_program(self, program_id: str) -> None:
         if program_id in self._programs:
             del self._programs[program_id]
 
+    def set_program_visibility(self, program_id: str, public: bool) -> None:
+        """Sets a program's visibility.
+
+        Args:
+            program_id: Program ID.
+            public: If ``True``, make the program visible to all.
+                If ``False``, make the program visible to just your account.
+
+        Raises:
+            RuntimeJobNotFound: if program not found (404)
+            QiskitRuntimeError: if update failed (401, 403)
+        """
+        try:
+            self._api_client.set_program_visibility(program_id, public)
+        except RequestsApiError as ex:
+            if ex.status_code == 404:
+                raise RuntimeJobNotFound(f"Program not found: {ex.message}") from None
+            raise QiskitRuntimeError(f"Failed to set program visibility: {ex}") from None
+
     def job(self, job_id: str) -> RuntimeJob:
         """Retrieve a runtime job.
 
diff --git a/qiskit/providers/ibmq/runtime/runtime_program.py b/qiskit/providers/ibmq/runtime/runtime_program.py
index da27ab580..17e482b9e 100644
--- a/qiskit/providers/ibmq/runtime/runtime_program.py
+++ b/qiskit/providers/ibmq/runtime/runtime_program.py
@@ -53,7 +53,8 @@ def __init__(
             max_execution_time: int = 0,
             version: str = "0",
             backend_requirements: Optional[Dict] = None,
-            creation_date: str = ""
+            creation_date: str = "",
+            is_public: bool = False
     ) -> None:
         """RuntimeProgram constructor.
 
@@ -68,6 +69,7 @@ def __init__(
             version: Program version.
             backend_requirements: Backend requirements.
             creation_date: Program creation date.
+            is_public: ``True`` if program is visible to all. ``False`` if it's only visible to you.
         """
         self._name = program_name
         self._id = program_id
@@ -79,6 +81,7 @@ def __init__(
         self._return_values: List[ProgramResult] = []
         self._interim_results: List[ProgramResult] = []
         self._creation_date = creation_date
+        self._is_public = is_public
 
         if parameters:
             for param in parameters:
@@ -149,7 +152,8 @@ def to_dict(self) -> Dict:
             "backend_requirements": self.backend_requirements,
             "parameters": self.parameters(),
             "return_values": self.return_values,
-            "interim_results": self.interim_results
+            "interim_results": self.interim_results,
+            "is_public": self._is_public
         }
 
     def parameters(self) -> 'ParameterNamespace':
@@ -250,6 +254,15 @@ def creation_date(self) -> str:
         """
         return self._creation_date
 
+    @property
+    def is_public(self) -> bool:
+        """Whether the program is visible to all.
+
+        Returns:
+            Whether the program is public.
+        """
+        return self._is_public
+
 
 class ProgramParameter(NamedTuple):
     """Program parameter."""
diff --git a/releasenotes/notes/added-runtime-visibility-db8da3f1df4507ca.yaml b/releasenotes/notes/added-runtime-visibility-db8da3f1df4507ca.yaml
new file mode 100644
index 000000000..de6c1774a
--- /dev/null
+++ b/releasenotes/notes/added-runtime-visibility-db8da3f1df4507ca.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    The user can now set the visibility (private/public) of a runtime program using
+    :meth:`qiskit.providers.ibmq.runtime.IBMRuntimeService.set_program_visibility`.
\ No newline at end of file


Test patch:
diff --git a/test/ibmq/runtime/fake_runtime_client.py b/test/ibmq/runtime/fake_runtime_client.py
index 88d89a48f..086f4c693 100644
--- a/test/ibmq/runtime/fake_runtime_client.py
+++ b/test/ibmq/runtime/fake_runtime_client.py
@@ -27,7 +27,7 @@ class BaseFakeProgram:
 
     def __init__(self, program_id, name, data, cost, description, version="1.0",
                  backend_requirements=None, parameters=None, return_values=None,
-                 interim_results=None):
+                 interim_results=None, is_public=False):
         """Initialize a fake program."""
         self._id = program_id
         self._name = name
@@ -39,6 +39,7 @@ def __init__(self, program_id, name, data, cost, description, version="1.0",
         self._parameters = parameters
         self._return_values = return_values
         self._interim_results = interim_results
+        self._is_public = is_public
 
     def to_dict(self, include_data=False):
         """Convert this program to a dictionary format."""
@@ -46,7 +47,8 @@ def to_dict(self, include_data=False):
                'name': self._name,
                'cost': self._cost,
                'description': self._description,
-               'version': self._version}
+               'version': self._version,
+               'is_public': self._is_public}
         if include_data:
             out['data'] = self._data
         if self._backend_requirements:
@@ -70,7 +72,7 @@ class BaseFakeRuntimeJob:
         "COMPLETED"
     ]
 
-    _executor = ThreadPoolExecutor()
+    _executor = ThreadPoolExecutor()  # pylint: disable=bad-option-value,consider-using-with
 
     def __init__(self, job_id, program_id, hub, group, project, backend_name, params):
         """Initialize a fake job."""
@@ -207,7 +209,7 @@ def list_programs(self):
 
     def program_create(self, program_data, name, description, max_execution_time, version="1.0",
                        backend_requirements=None, parameters=None, return_values=None,
-                       interim_results=None):
+                       interim_results=None, is_public=False):
         """Create a program."""
         if isinstance(program_data, str):
             with open(program_data, 'rb') as file:
@@ -218,7 +220,8 @@ def program_create(self, program_data, name, description, max_execution_time, ve
         self._programs[program_id] = BaseFakeProgram(
             program_id=program_id, name=name, data=program_data, cost=max_execution_time,
             description=description, version=version, backend_requirements=backend_requirements,
-            parameters=parameters, return_values=return_values, interim_results=interim_results)
+            parameters=parameters, return_values=return_values, interim_results=interim_results,
+            is_public=is_public)
         return {'id': program_id}
 
     def program_get(self, program_id: str):
@@ -266,6 +269,16 @@ def jobs_get(self, limit=None, skip=None):
         return {"jobs": [job.to_dict() for job in jobs],
                 "count": len(self._jobs)}
 
+    def set_program_visibility(self, program_id: str, public: bool) -> None:
+        """Sets a program's visibility.
+
+        Args:
+            program_id: Program ID.
+            public: If ``True``, make the program visible to all.
+                If ``False``, make the program visible to just your account.
+        """
+        self._programs[program_id]._is_public = public
+
     def job_results(self, job_id):
         """Get the results of a program job."""
         return self._get_job(job_id).result()
diff --git a/test/ibmq/runtime/test_runtime_integration.py b/test/ibmq/runtime/test_runtime_integration.py
index c7f776c9a..f300d20b1 100644
--- a/test/ibmq/runtime/test_runtime_integration.py
+++ b/test/ibmq/runtime/test_runtime_integration.py
@@ -22,6 +22,7 @@
 from qiskit.providers.jobstatus import JobStatus, JOB_FINAL_STATES
 from qiskit.test.reference_circuits import ReferenceCircuits
 from qiskit.providers.ibmq.exceptions import IBMQNotAuthorizedError
+from qiskit.providers.ibmq.runtime.runtime_program import RuntimeProgram
 from qiskit.providers.ibmq.runtime.exceptions import (RuntimeDuplicateProgramError,
                                                       RuntimeProgramNotFound,
                                                       RuntimeJobFailureError,
@@ -149,6 +150,20 @@ def test_upload_program(self):
         self.assertTrue(program)
         self.assertEqual(max_execution_time, program.max_execution_time)
 
+    def test_set_visibility(self):
+        """Test setting the visibility of a program."""
+        program_id = self._upload_program()
+        # Get the initial visibility
+        prog: RuntimeProgram = self.provider.runtime.program(program_id)
+        start_vis = prog.is_public
+        # Flip the original value
+        self.provider.runtime.set_program_visibility(program_id, not start_vis)
+        # Get the new visibility
+        prog: RuntimeProgram = self.provider.runtime.program(program_id, refresh=True)
+        end_vis = prog.is_public
+        # Verify changed
+        self.assertNotEqual(start_vis, end_vis)
+
     def test_upload_program_conflict(self):
         """Test uploading a program with conflicting name."""
         name = self._get_program_name()