Repository: huggingface/huggingface_hub (python)

Issue:
Add list_spaces
**Is your feature request related to a problem? Please describe.**
Allow users listing spaces similar to how they can list models and datasets

We can use something like this for example

```
HF_LIST_ENDPOINT = HF_ENDPOINT + "/api/spaces?filter={type}"
r = requests.get(HF_LIST_ENDPOINT.format(type=type))
r.raise_for_status()
d = r.json()
```

cc @lvwerra 


Pull request:
Add list_spaces to HfApi
First try; fixes #881.  :-) 
Reused the `SpaceInfo` class, but not sure that was the way to go because of unused fields:
```
SpaceInfo: {
	id: BigSalmon/Streamlit_Headline_Writer
	sha: None
	lastModified: None
	siblings: None
	private: None
	author: None
	sdk: streamlit
}
```

cc @osanseviero @LysandreJik

Patch:
diff --git a/setup.py b/setup.py
index 9a2eae17..01a512cf 100644
--- a/setup.py
+++ b/setup.py
@@ -43,7 +43,7 @@ extras["testing"] = [
 ]
 
 extras["quality"] = [
-    "black~=22.0",
+    "black==22.3",
     "isort>=5.5.4",
     "flake8>=3.8.3",
 ]
diff --git a/src/huggingface_hub/hf_api.py b/src/huggingface_hub/hf_api.py
index 43c1cfff..a744cea5 100644
--- a/src/huggingface_hub/hf_api.py
+++ b/src/huggingface_hub/hf_api.py
@@ -44,7 +44,6 @@ from .constants import (
 from .utils import logging
 from .utils._deprecation import _deprecate_positional_args
 from .utils._errors import _raise_for_status, _raise_with_request_id
-from .utils._fixes import JSONDecodeError
 from .utils.endpoint_helpers import (
     AttributeDictionary,
     DatasetFilter,
@@ -1076,6 +1075,91 @@ class HfApi:
         d = r.json()
         return [MetricInfo(**x) for x in d]
 
+    def list_spaces(
+        self,
+        *,
+        filter: Union[str, Iterable[str], None] = None,
+        author: Optional[str] = None,
+        search: Optional[str] = None,
+        sort: Union[Literal["lastModified"], str, None] = None,
+        direction: Optional[Literal[-1]] = None,
+        limit: Optional[int] = None,
+        datasets: Union[str, Iterable[str], None] = None,
+        models: Union[str, Iterable[str], None] = None,
+        linked: Optional[bool] = None,
+        full: Optional[bool] = None,
+        use_auth_token: Optional[str] = None,
+    ) -> List[SpaceInfo]:
+        """
+        Get the public list of all Spaces on huggingface.co
+
+        Args:
+            filter `str` or `Iterable`, *optional*):
+                A string tag or list of tags that can be used to identify Spaces on the Hub.
+            author (`str`, *optional*):
+                A string which identify the author of the returned Spaces.
+            search (`str`, *optional*):
+                A string that will be contained in the returned Spaces.
+            sort (`Literal["lastModified"]` or `str`, *optional*):
+                The key with which to sort the resulting Spaces. Possible
+                values are the properties of the `SpaceInfo` class.
+            direction (`Literal[-1]` or `int`, *optional*):
+                Direction in which to sort. The value `-1` sorts by descending
+                order while all other values sort by ascending order.
+            limit (`int`, *optional*):
+                The limit on the number of Spaces fetched. Leaving this option
+                to `None` fetches all Spaces.
+            datasets (`str` or `Iterable`, *optional*):
+                Whether to return Spaces that make use of a dataset.
+                The name of a specific dataset can be passed as a string.
+            models (`str` or `Iterable`, *optional*):
+                Whether to return Spaces that make use of a model.
+                The name of a specific model can be passed as a string.
+            linked (`bool`, *optional*):
+                Whether to return Spaces that make use of either a model or a dataset.
+            full (`bool`, *optional*):
+                Whether to fetch all Spaces data, including the `lastModified`
+                and the `cardData`.
+            use_auth_token (`bool` or `str`, *optional*):
+                Whether to use the `auth_token` provided from the
+                `huggingface_hub` cli. If not logged in, a valid `auth_token`
+                can be passed in as a string.
+
+        Returns:
+            `List[SpaceInfo]`: a list of [`SpaceInfo`] objects
+        """
+        path = f"{self.endpoint}/api/spaces"
+        if use_auth_token:
+            token, name = self._validate_or_retrieve_token(use_auth_token)
+        headers = {"authorization": f"Bearer {token}"} if use_auth_token else None
+        params = {}
+        if filter is not None:
+            params.update({"filter": filter})
+        if author is not None:
+            params.update({"author": author})
+        if search is not None:
+            params.update({"search": search})
+        if sort is not None:
+            params.update({"sort": sort})
+        if direction is not None:
+            params.update({"direction": direction})
+        if limit is not None:
+            params.update({"limit": limit})
+        if full is not None:
+            if full:
+                params.update({"full": True})
+        if linked is not None:
+            if linked:
+                params.update({"linked": True})
+        if datasets is not None:
+            params.update({"datasets": datasets})
+        if models is not None:
+            params.update({"models": models})
+        r = requests.get(path, params=params, headers=headers)
+        r.raise_for_status()
+        d = r.json()
+        return [SpaceInfo(**x) for x in d]
+
     def model_info(
         self,
         repo_id: str,
@@ -1559,16 +1643,7 @@ class HfApi:
             headers={"authorization": f"Bearer {token}"},
             json=json,
         )
-        try:
-            _raise_for_status(r)
-        except requests.exceptions.RequestException as e:
-            try:
-                message = e.response.json()["error"]
-            except JSONDecodeError:
-                message = e.response.text
-            except AttributeError:
-                message = e.args[0]
-            raise type(e)(message) from e
+        _raise_for_status(r)
 
     def update_repo_visibility(
         self,
@@ -2307,6 +2382,7 @@ model_info = api.model_info
 list_datasets = api.list_datasets
 dataset_info = api.dataset_info
 
+list_spaces = api.list_spaces
 space_info = api.space_info
 
 repo_info = api.repo_info
diff --git a/src/huggingface_hub/utils/_errors.py b/src/huggingface_hub/utils/_errors.py
index 5966db50..2617acd8 100644
--- a/src/huggingface_hub/utils/_errors.py
+++ b/src/huggingface_hub/utils/_errors.py
@@ -15,6 +15,9 @@ class RepositoryNotFoundError(HTTPError):
     ```
     """
 
+    def __init__(self, message, response):
+        super().__init__(message, response=response)
+
 
 class RevisionNotFoundError(HTTPError):
     """
@@ -28,9 +31,11 @@ class RevisionNotFoundError(HTTPError):
     >>> hf_hub_download('bert-base-cased', 'config.json', revision='<non-existant-revision>')
     huggingface_hub.utils._errors.RevisionNotFoundError: 404 Client Error: Revision Not Found for url: <url>
     ```
-
     """
 
+    def __init__(self, message, response):
+        super().__init__(message, response=response)
+
 
 class EntryNotFoundError(HTTPError):
     """
@@ -46,42 +51,60 @@ class EntryNotFoundError(HTTPError):
     ```
     """
 
+    def __init__(self, message, response):
+        super().__init__(message, response=response)
+
+
+def _add_request_id_to_error_args(e, request_id):
+    if request_id is not None and len(e.args) > 0 and isinstance(e.args[0], str):
+        e.args = (e.args[0] + f" (Request ID: {request_id})",) + e.args[1:]
+
 
-def _raise_for_status(request):
+def _raise_for_status(response):
     """
-    Internal version of `request.raise_for_status()` that will refine a
+    Internal version of `response.raise_for_status()` that will refine a
     potential HTTPError.
     """
-    request_id = request.headers.get("X-Request-Id")
-
-    if "X-Error-Code" in request.headers:
-        error_code = request.headers["X-Error-Code"]
-        if error_code == "RepoNotFound":
-            raise RepositoryNotFoundError(
-                f"404 Client Error: Repository Not Found for url: {request.url}. If the"
-                " repo is private, make sure you are authenticated. (Request ID:"
-                f" {request_id})"
-            )
-        elif error_code == "RevisionNotFound":
-            raise RevisionNotFoundError(
-                f"404 Client Error: Revision Not Found for url: {request.url}. (Request"
-                f" ID: {request_id})"
-            )
-        elif error_code == "EntryNotFound":
-            raise EntryNotFoundError(
-                f"404 Client Error: Entry Not Found for url: {request.url}. (Request"
-                f" ID: {request_id})"
+    request_id = response.headers.get("X-Request-Id")
+    try:
+        response.raise_for_status()
+    except HTTPError as e:
+        if "X-Error-Code" in response.headers:
+            error_code = response.headers["X-Error-Code"]
+            if error_code == "RepoNotFound":
+                message = (
+                    f"{response.status_code} Client Error: Repository Not Found for"
+                    f" url: {response.url}. If the repo is private, make sure you are"
+                    " authenticated."
+                )
+                e = RepositoryNotFoundError(message, response)
+            elif error_code == "RevisionNotFound":
+                message = (
+                    f"{response.status_code} Client Error: Revision Not Found for url:"
+                    f" {response.url}."
+                )
+                e = RevisionNotFoundError(message, response)
+            if error_code == "EntryNotFound":
+                message = (
+                    f"{response.status_code} Client Error: Entry Not Found for url:"
+                    f" {response.url}."
+                )
+                e = EntryNotFoundError(message, response)
+            _add_request_id_to_error_args(e, request_id)
+            raise e
+
+        if response.status_code == 401:
+            # The repo was not found and the user is not Authenticated
+            message = (
+                f"{response.status_code} Client Error: Repository Not Found for url:"
+                f" {response.url}. If the repo is private, make sure you are"
+                " authenticated."
             )
+            e = RepositoryNotFoundError(message, response)
 
-    if request.status_code == 401:
-        # The repo was not found and the user is not Authenticated
-        raise RepositoryNotFoundError(
-            f"401 Client Error: Repository Not Found for url: {request.url}. If the"
-            " repo is private, make sure you are authenticated. (Request ID:"
-            f" {request_id})"
-        )
+        _add_request_id_to_error_args(e, request_id)
 
-    _raise_with_request_id(request)
+        raise e
 
 
 def _raise_with_request_id(request):
@@ -89,7 +112,6 @@ def _raise_with_request_id(request):
     try:
         request.raise_for_status()
     except Exception as e:
-        if request_id is not None and len(e.args) > 0 and isinstance(e.args[0], str):
-            e.args = (e.args[0] + f" (Request ID: {request_id})",) + e.args[1:]
+        _add_request_id_to_error_args(e, request_id)
 
         raise e


Test patch:
diff --git a/tests/test_hf_api.py b/tests/test_hf_api.py
index 219f5ef1..a8d53887 100644
--- a/tests/test_hf_api.py
+++ b/tests/test_hf_api.py
@@ -45,6 +45,7 @@ from huggingface_hub.hf_api import (
     MetricInfo,
     ModelInfo,
     ModelSearchArguments,
+    SpaceInfo,
     erase_from_credential_store,
     read_from_credential_store,
     repo_type_and_id_from_hf_id,
@@ -1239,6 +1240,88 @@ class HfApiPublicTest(unittest.TestCase):
             )
         )
 
+    @with_production_testing
+    def test_list_spaces_full(self):
+        _api = HfApi()
+        spaces = _api.list_spaces(full=True)
+        self.assertGreater(len(spaces), 100)
+        space = spaces[0]
+        self.assertIsInstance(space, SpaceInfo)
+        self.assertTrue(any(space.cardData for space in spaces))
+
+    @with_production_testing
+    def test_list_spaces_author(self):
+        _api = HfApi()
+        spaces = _api.list_spaces(author="evaluate-metric")
+        self.assertGreater(len(spaces), 10)
+        self.assertTrue(
+            set([space.id for space in spaces]).issuperset(
+                set(["evaluate-metric/trec_eval", "evaluate-metric/perplexity"])
+            )
+        )
+
+    @with_production_testing
+    def test_list_spaces_search(self):
+        _api = HfApi()
+        spaces = _api.list_spaces(search="wikipedia")
+        space = spaces[0]
+        self.assertTrue("wikipedia" in space.id.lower())
+
+    @with_production_testing
+    def test_list_spaces_sort_and_direction(self):
+        _api = HfApi()
+        spaces_descending_likes = _api.list_spaces(sort="likes", direction=-1)
+        spaces_ascending_likes = _api.list_spaces(sort="likes")
+        self.assertGreater(
+            spaces_descending_likes[0].likes, spaces_descending_likes[1].likes
+        )
+        self.assertLess(
+            spaces_ascending_likes[-2].likes, spaces_ascending_likes[-1].likes
+        )
+
+    @with_production_testing
+    def test_list_spaces_limit(self):
+        _api = HfApi()
+        spaces = _api.list_spaces(limit=5)
+        self.assertEqual(len(spaces), 5)
+
+    @with_production_testing
+    def test_list_spaces_with_models(self):
+        _api = HfApi()
+        spaces = _api.list_spaces(models="bert-base-uncased")
+        self.assertTrue("bert-base-uncased" in getattr(spaces[0], "models", []))
+
+    @with_production_testing
+    def test_list_spaces_with_datasets(self):
+        _api = HfApi()
+        spaces = _api.list_spaces(datasets="wikipedia")
+        self.assertTrue("wikipedia" in getattr(spaces[0], "datasets", []))
+
+    @with_production_testing
+    def test_list_spaces_linked(self):
+        _api = HfApi()
+        spaces = _api.list_spaces(linked=True)
+        self.assertTrue(
+            any((getattr(space, "models", None) is not None) for space in spaces)
+        )
+        self.assertTrue(
+            any((getattr(space, "datasets", None) is not None) for space in spaces)
+        )
+        self.assertTrue(
+            any(
+                (getattr(space, "models", None) is not None)
+                and getattr(space, "datasets", None) is not None
+            )
+            for space in spaces
+        )
+        self.assertTrue(
+            all(
+                (getattr(space, "models", None) is not None)
+                or getattr(space, "datasets", None) is not None
+            )
+            for space in spaces
+        )
+
 
 class HfApiPrivateTest(HfApiCommonTestWithLogin):
     @retry_endpoint
@@ -1280,6 +1363,12 @@ class HfApiPrivateTest(HfApiCommonTestWithLogin):
         new = len(self._api.list_models(use_auth_token=self._token))
         self.assertGreater(new, orig)
 
+    @with_production_testing
+    def test_list_private_spaces(self):
+        orig = len(self._api.list_spaces())
+        new = len(self._api.list_spaces(use_auth_token=self._token))
+        self.assertGreaterEqual(new, orig)
+
 
 class HfFolderTest(unittest.TestCase):
     def test_token_workflow(self):
diff --git a/tests/test_utils_errors.py b/tests/test_utils_errors.py
new file mode 100644
index 00000000..f0b136b3
--- /dev/null
+++ b/tests/test_utils_errors.py
@@ -0,0 +1,85 @@
+import unittest
+
+from huggingface_hub.utils._errors import (
+    EntryNotFoundError,
+    RepositoryNotFoundError,
+    RevisionNotFoundError,
+    _raise_for_status,
+    _raise_with_request_id,
+)
+from requests.exceptions import HTTPError
+from requests.models import Response
+
+
+class TestErrorUtils(unittest.TestCase):
+    def test__raise_for_status_repo_not_found(self):
+        response = Response()
+        response.headers = {"X-Error-Code": "RepoNotFound", "X-Request-Id": 123}
+        response.status_code = 404
+        with self.assertRaisesRegex(
+            RepositoryNotFoundError, "Repository Not Found"
+        ) as context:
+            _raise_for_status(response)
+
+        self.assertEqual(context.exception.response.status_code, 404)
+        self.assertIn("Request ID: 123", str(context.exception))
+
+    def test__raise_for_status_repo_not_found_without_error_code(self):
+        response = Response()
+        response.headers = {"X-Request-Id": 123}
+        response.status_code = 401
+        with self.assertRaisesRegex(
+            RepositoryNotFoundError, "Repository Not Found"
+        ) as context:
+            _raise_for_status(response)
+
+        self.assertEqual(context.exception.response.status_code, 401)
+        self.assertIn("Request ID: 123", str(context.exception))
+
+    def test_raise_for_status_revision_not_found(self):
+        response = Response()
+        response.headers = {"X-Error-Code": "RevisionNotFound", "X-Request-Id": 123}
+        response.status_code = 404
+        with self.assertRaisesRegex(
+            RevisionNotFoundError, "Revision Not Found"
+        ) as context:
+            _raise_for_status(response)
+
+        self.assertEqual(context.exception.response.status_code, 404)
+        self.assertIn("Request ID: 123", str(context.exception))
+
+    def test_raise_for_status_entry_not_found(self):
+        response = Response()
+        response.headers = {"X-Error-Code": "EntryNotFound", "X-Request-Id": 123}
+        response.status_code = 404
+        with self.assertRaisesRegex(EntryNotFoundError, "Entry Not Found") as context:
+            _raise_for_status(response)
+
+        self.assertEqual(context.exception.response.status_code, 404)
+        self.assertIn("Request ID: 123", str(context.exception))
+
+    def test_raise_for_status_fallback(self):
+        response = Response()
+        response.status_code = 404
+        response.headers = {
+            "X-Request-Id": "test-id",
+        }
+        response.url = "test_URL"
+        with self.assertRaisesRegex(HTTPError, "Request ID: test-id") as context:
+            _raise_for_status(response)
+
+        self.assertEqual(context.exception.response.status_code, 404)
+        self.assertEqual(context.exception.response.url, "test_URL")
+
+    def test_raise_with_request_id(self):
+        response = Response()
+        response.status_code = 404
+        response.headers = {
+            "X-Request-Id": "test-id",
+        }
+        response.url = "test_URL"
+        with self.assertRaisesRegex(HTTPError, "Request ID: test-id") as context:
+            _raise_with_request_id(response)
+
+        self.assertEqual(context.exception.response.status_code, 404)
+        self.assertEqual(context.exception.response.url, "test_URL")