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

Issue:
Add support for filtering experiments by hub/group/project and visibility

### What is the expected behavior?

#### Hub/Group/Project

The `GET /experiments` API allows filtering experiments by a specific `hub_id`, `group_id` and `project_id`. The following combinations are allowed:

- `hub_id` only
- `hub_id` and `group_id`
- `hub_id` and `group_id` and `project_id`

If not specified, the service returns experiments that:

- The user owns
- That are public
- That are shared at the hub, group or project level and are in a provider that the user is in

Passing specific `hub_id`, `group_id` and `project_id` filter values narrows that query on the server side and should retrieve resources faster.

#### Visibility

Currently the `GET /experiments` API supports filtering by `visibility=public`. Since we probably don't want to duplicate the valid `visibility` filter values on the client side in Qiskit, I would suggest adding an `is_public` kwarg to the client code which defaults to False so it's not included in the `GET /experiments` call but if True would include the filter like `GET /experiments?visibility=public`.


Discussion / hints:
> Currently the `GET /experiments` API supports filtering by `visibility=public`. Since we probably don't want to duplicate the valid `visibility` filter values on the client side in Qiskit, I would suggest adding an `is_public` kwarg to the client code which defaults to False so it's not included in the `GET /experiments` call but if True would include the filter like `GET /experiments?visibility=public`.

There is now also `visibility=!public` support so having a kwarg like `is_public` default to False could be confusing, so maybe there could be an `is_public` kwarg that defaults to None meaning don't apply the filter, but if it's True you'd pass `visibility=public` and if it's False you'd pass `visibility=!public`. An alternative idea is having an `is_public` and `not_public` kwarg but those could be confusing as well, i.e. what if someone incorrectly specified `is_public=True` and `not_public=True`? It's probably cleaner to just have a single kwarg.

Fix patch:
diff --git a/qiskit/providers/ibmq/api/clients/experiment.py b/qiskit/providers/ibmq/api/clients/experiment.py
index 7b95fcfb7..a4dd1e318 100644
--- a/qiskit/providers/ibmq/api/clients/experiment.py
+++ b/qiskit/providers/ibmq/api/clients/experiment.py
@@ -13,7 +13,7 @@
 """Client for accessing IBM Quantum Experience experiment services."""
 
 import logging
-from typing import List, Dict, Optional, Union, Tuple
+from typing import List, Dict, Optional, Union
 
 from qiskit.providers.ibmq.credentials import Credentials
 
@@ -53,7 +53,9 @@ def experiments(
             tags: Optional[List[str]] = None,
             hub: Optional[str] = None,
             group: Optional[str] = None,
-            project: Optional[str] = None
+            project: Optional[str] = None,
+            exclude_public: Optional[bool] = False,
+            public_only: Optional[bool] = False
     ) -> Dict:
         """Retrieve experiments, with optional filtering.
 
@@ -68,13 +70,15 @@ def experiments(
             hub: Filter by hub.
             group: Filter by hub and group.
             project: Filter by hub, group, and project.
+            exclude_public: Whether or not to exclude experiments with a public share level.
+            public_only: Whether or not to only return experiments with a public share level.
 
         Returns:
             A list of experiments and the marker, if applicable.
         """
         resp = self.base_api.experiments(
             limit, marker, backend_name, experiment_type, start_time, device_components, tags,
-            hub, group, project)
+            hub, group, project, exclude_public, public_only)
         return resp
 
     def experiment_get(self, experiment_id: str) -> Dict:
diff --git a/qiskit/providers/ibmq/api/rest/root.py b/qiskit/providers/ibmq/api/rest/root.py
index a1e10be1a..c1c578130 100644
--- a/qiskit/providers/ibmq/api/rest/root.py
+++ b/qiskit/providers/ibmq/api/rest/root.py
@@ -13,7 +13,7 @@
 """Root REST adapter."""
 
 import logging
-from typing import Dict, List, Any, Union, Optional, Tuple
+from typing import Dict, List, Any, Union, Optional
 import json
 
 from .base import RestAdapterBase
@@ -157,7 +157,9 @@ def experiments(
             tags: Optional[List[str]] = None,
             hub: Optional[str] = None,
             group: Optional[str] = None,
-            project: Optional[str] = None
+            project: Optional[str] = None,
+            exclude_public: Optional[bool] = False,
+            public_only: Optional[bool] = False
     ) -> Dict:
         """Return experiment data.
 
@@ -172,6 +174,8 @@ def experiments(
             hub: Filter by hub.
             group: Filter by hub and group.
             project: Filter by hub, group, and project.
+            exclude_public: Whether or not to exclude experiments with a public share level.
+            public_only: Whether or not to only return experiments with a public share level.
 
         Returns:
             JSON response.
@@ -198,6 +202,10 @@ def experiments(
             params['group_id'] = group
         if project:
             params['project_id'] = project
+        if exclude_public:
+            params['visibility'] = '!public'
+        elif public_only:
+            params['visibility'] = 'public'
         return self.session.get(url, params=params).json()
 
     def experiment_devices(self) -> Dict:
diff --git a/qiskit/providers/ibmq/experiment/experimentservice.py b/qiskit/providers/ibmq/experiment/experimentservice.py
index ef8aaa3bb..75cabe29a 100644
--- a/qiskit/providers/ibmq/experiment/experimentservice.py
+++ b/qiskit/providers/ibmq/experiment/experimentservice.py
@@ -107,7 +107,9 @@ def experiments(
             tags_operator: Optional[str] = "OR",
             hub: Optional[str] = None,
             group: Optional[str] = None,
-            project: Optional[str] = None
+            project: Optional[str] = None,
+            exclude_public: Optional[bool] = False,
+            public_only: Optional[bool] = False
     ) -> List[Experiment]:
         """Retrieve all experiments, with optional filtering.
 
@@ -141,6 +143,12 @@ def experiments(
             group: Filter by hub and group. `hub` must also be specified if `group` is.
             project: Filter by hub, group, and project. `hub` and `group` must also be
                 specified if `project` is.
+            exclude_public: If ``True``, experiments with ``share_level=public``
+                (that is, experiments visible to all users) will not be returned.
+                Cannot be ``True`` if `public_only` is ``True``.
+            public_only: If ``True``, only experiments with ``share_level=public``
+                (that is, experiments visible to all users) will be returned.
+                Cannot be ``True`` if `public_only` is ``True``.
 
         Returns:
             A list of experiments.
@@ -176,12 +184,16 @@ def experiments(
                 raise ValueError('{} is not a valid `tags_operator`. Valid values are '
                                  '"AND" and "OR".'.format(tags_operator))
 
+        if exclude_public and public_only:
+            raise ValueError('exclude_public and public_only cannot both be True')
+
         experiments = []
         marker = None
         while limit is None or limit > 0:
             raw_data = self._api_client.experiments(
                 limit, marker, backend_name, type, start_time_filters,
-                device_components, tags_filter, hub, group, project)
+                device_components, tags_filter, hub, group, project,
+                exclude_public, public_only)
             marker = raw_data.get('marker')
             for exp in raw_data['experiments']:
                 experiments.append(Experiment.from_remote_data(self._provider, exp))
diff --git a/releasenotes/notes/experiments-hgp-88b2416769125e38.yaml b/releasenotes/notes/experiments-hgp-88b2416769125e38.yaml
index 013355e0d..0dd69bb37 100644
--- a/releasenotes/notes/experiments-hgp-88b2416769125e38.yaml
+++ b/releasenotes/notes/experiments-hgp-88b2416769125e38.yaml
@@ -2,5 +2,5 @@
 features:
   - |
     The method
-    :meth:`qiskit.providers.ibmq.experiment.ExperimentService.experiments`
+    :meth:`qiskit.providers.ibmq.experimentservice.ExperimentService.experiments`
     now accepts ``hub``, ``group``, and ``project`` as filtering keywords.
diff --git a/releasenotes/notes/experiments-public-filters-62a0bae8c38d7512.yaml b/releasenotes/notes/experiments-public-filters-62a0bae8c38d7512.yaml
new file mode 100644
index 000000000..0d2dca563
--- /dev/null
+++ b/releasenotes/notes/experiments-public-filters-62a0bae8c38d7512.yaml
@@ -0,0 +1,6 @@
+---
+features:
+  - |
+    The method
+    :meth:`qiskit.providers.ibmq.experimentservice.ExperimentService.experiments`
+    now accepts ``exclude_public`` and ``public_only`` as filtering keywords.


Test patch:
diff --git a/test/ibmq/test_experiment.py b/test/ibmq/test_experiment.py
index 4e5737376..da9932af4 100644
--- a/test/ibmq/test_experiment.py
+++ b/test/ibmq/test_experiment.py
@@ -16,7 +16,7 @@
 import uuid
 from unittest import mock, SkipTest, skipIf
 from datetime import datetime, timedelta
-from typing import Optional
+from typing import Optional, Union
 
 from qiskit.providers.ibmq.experiment.experiment import Experiment
 from qiskit.providers.ibmq.experiment.analysis_result import AnalysisResult, Fit, DeviceComponent
@@ -235,6 +235,62 @@ def test_experiments_with_hgp_error(self):
                 for key in missing_keys:
                     self.assertIn(key, str(ex_cm.exception))
 
+    def test_experiments_with_exclude_public(self):
+        """Tests retrieving experiments with exclude_public filter."""
+        # Make sure that we have at least one public experiment and one non-public
+        # experiment.
+        public_exp = self._create_experiment(share_level=ExperimentShareLevel.PUBLIC)
+        non_public_exp = self._create_experiment()
+
+        experiments = self.provider.experiment.experiments(exclude_public=True)
+        # The public experiment we just created should not be in the set.
+        non_public_experiment_uuids = []
+        for experiment in experiments:
+            self.assertNotEqual(
+                experiment.share_level, ExperimentShareLevel.PUBLIC,
+                'Public experiment should not be returned with exclude_public filter: %s' %
+                experiment)
+            non_public_experiment_uuids.append(experiment.uuid)
+        self.assertIn(
+            non_public_exp.uuid, non_public_experiment_uuids,
+            'Non-public experiment not returned with exclude_public filter: %s' %
+            non_public_exp)
+        self.assertNotIn(
+            public_exp.uuid, non_public_experiment_uuids,
+            'Public experiment returned with exclude_public filter: %s' %
+            public_exp)
+
+    def test_experiments_with_public_only(self):
+        """Tests retrieving experiments with public_only filter."""
+        # Make sure that we have at least one public experiment and one non-public
+        # experiment.
+        public_exp = self._create_experiment(share_level=ExperimentShareLevel.PUBLIC)
+        non_public_exp = self._create_experiment()
+
+        experiments = self.provider.experiment.experiments(public_only=True)
+        public_experiment_uuids = []
+        for experiment in experiments:
+            self.assertEqual(
+                experiment.share_level, ExperimentShareLevel.PUBLIC,
+                'Only public experiments should be returned with public_only filter: %s' %
+                experiment)
+            public_experiment_uuids.append(experiment.uuid)
+        self.assertIn(
+            public_exp.uuid, public_experiment_uuids,
+            'Public experiment not returned with public_only filter: %s' %
+            public_exp)
+        self.assertNotIn(
+            non_public_exp.uuid, public_experiment_uuids,
+            'Non-public experiment returned with public_only filter: %s' %
+            non_public_exp)
+
+    def test_experiments_with_public_filters_error(self):
+        """Tests that exclude_public and public_only cannot both be True."""
+        with self.assertRaisesRegex(
+                ValueError,
+                'exclude_public and public_only cannot both be True'):
+            self.provider.experiment.experiments(exclude_public=True, public_only=True)
+
     def test_retrieve_experiment(self):
         """Test retrieving an experiment by its ID."""
         exp = self.experiments[0]
@@ -604,15 +660,17 @@ def test_analysis_results_limit(self):
     def _create_experiment(
             self,
             backend_name: Optional[str] = None,
-            start_dt: Optional[datetime] = None
+            start_dt: Optional[datetime] = None,
+            share_level: Optional[Union[ExperimentShareLevel, str]] = None
     ) -> Experiment:
         backend_name = backend_name or self.experiments[0].backend_name
-        new_exp = Experiment(
+        new_exp = Experiment(  # pylint: disable=unexpected-keyword-arg
             provider=self.provider,
             backend_name=backend_name,
             experiment_type='test',
             tags=['qiskit-test'],
-            start_datetime=start_dt
+            start_datetime=start_dt,
+            share_level=share_level
         )
         self.provider.experiment.upload_experiment(new_exp)
         self.experiments_to_delete.append(new_exp.uuid)