Repository: huggingface/huggingface_hub
Pull request #1354 (2023-02-21 10:37:59)

Expose `RepoCard` at top level + few qol improvements
Resolve https://github.com/huggingface/huggingface_hub/issues/1348.
No need to add a `SpaceCard` class, it wouldn't have any additional features.

I took the opportunity to add a few QOL improvement to the `CardData` object (`.get`, `__getitem__`, `__setitem__`, `__contains__`, `.pop`). More of these could be added but honestly not worth the hassle (those 5 cover 99% of the use cases). Could have been simpler to just inherit from `dict` but this way we can explicitly add an "export" step when serializing the metadata. 

Added some tests and completed a docstring.

HuggingFaceDocBuilderDev: The docs for this PR live [here](https://moon-ci-docs.huggingface.co/docs/huggingface_hub/pr_1354). All of your documentation changes will be reflected on that endpoint.
Wauplin: Thanks for the reviews and feedback @merveenoyan @nateraw 
I'm merging this PR :)

Patch:
diff --git a/docs/source/package_reference/cards.mdx b/docs/source/package_reference/cards.mdx
index 851777f5..d10f8146 100644
--- a/docs/source/package_reference/cards.mdx
+++ b/docs/source/package_reference/cards.mdx
@@ -7,11 +7,12 @@ get a feel for how you would use these utilities in your own projects.
 
 ## Repo Card
 
-The `RepoCard` object is the parent class of [`ModelCard`] and [`DatasetCard`].
+The `RepoCard` object is the parent class of [`ModelCard`], [`DatasetCard`] and `SpaceCard`.
 
 [[autodoc]] huggingface_hub.repocard.RepoCard
     - __init__
     - all
+
 ## Card Data
 
 The [`CardData`] object is the parent class of [`ModelCardData`] and [`DatasetCardData`].
@@ -19,6 +20,7 @@ The [`CardData`] object is the parent class of [`ModelCardData`] and [`DatasetCa
 [[autodoc]] huggingface_hub.repocard_data.CardData
 
 ## Model Cards
+
 ### ModelCard
 
 [[autodoc]] ModelCard
@@ -39,6 +41,16 @@ Dataset cards are also known as Data Cards in the ML Community.
 
 [[autodoc]] DatasetCardData
 
+## Space Cards
+
+### SpaceCard
+
+[[autodoc]] SpaceCard
+
+### SpaceCardData
+
+[[autodoc]] SpaceCardData
+
 ## Utilities
 
 ### EvalResult
diff --git a/src/huggingface_hub/__init__.py b/src/huggingface_hub/__init__.py
index d762a2ca..5e2793b0 100644
--- a/src/huggingface_hub/__init__.py
+++ b/src/huggingface_hub/__init__.py
@@ -179,6 +179,8 @@ _SUBMOD_ATTRS = {
     "repocard": [
         "DatasetCard",
         "ModelCard",
+        "RepoCard",
+        "SpaceCard",
         "metadata_eval_result",
         "metadata_load",
         "metadata_save",
@@ -189,6 +191,7 @@ _SUBMOD_ATTRS = {
         "DatasetCardData",
         "EvalResult",
         "ModelCardData",
+        "SpaceCardData",
     ],
     "repository": [
         "Repository",
@@ -432,6 +435,8 @@ if TYPE_CHECKING:  # pragma: no cover
     from .repocard import (
         DatasetCard,  # noqa: F401
         ModelCard,  # noqa: F401
+        RepoCard,  # noqa: F401
+        SpaceCard,  # noqa: F401
         metadata_eval_result,  # noqa: F401
         metadata_load,  # noqa: F401
         metadata_save,  # noqa: F401
@@ -442,6 +447,7 @@ if TYPE_CHECKING:  # pragma: no cover
         DatasetCardData,  # noqa: F401
         EvalResult,  # noqa: F401
         ModelCardData,  # noqa: F401
+        SpaceCardData,  # noqa: F401
     )
     from .repository import Repository  # noqa: F401
     from .utils import (
diff --git a/src/huggingface_hub/hf_api.py b/src/huggingface_hub/hf_api.py
index a9bf59d7..af9a35ba 100644
--- a/src/huggingface_hub/hf_api.py
+++ b/src/huggingface_hub/hf_api.py
@@ -3455,7 +3455,7 @@ class HfApi:
 
         Creating a Pull Request with changes can also be done at once with [`HfApi.create_commit`];
 
-        This is a wrapper around [`HfApi.create_discusssion`].
+        This is a wrapper around [`HfApi.create_discussion`].
 
         Args:
             repo_id (`str`):
diff --git a/src/huggingface_hub/repocard.py b/src/huggingface_hub/repocard.py
index c99d7819..bb4068ea 100644
--- a/src/huggingface_hub/repocard.py
+++ b/src/huggingface_hub/repocard.py
@@ -13,6 +13,7 @@ from huggingface_hub.repocard_data import (
     DatasetCardData,
     EvalResult,
     ModelCardData,
+    SpaceCardData,
     eval_results_to_model_index,
     model_index_to_eval_results,
 )
@@ -463,6 +464,12 @@ class DatasetCard(RepoCard):
         return super().from_template(card_data, template_path, **template_kwargs)
 
 
+class SpaceCard(RepoCard):
+    card_data_class = SpaceCardData
+    default_template_path = TEMPLATE_MODELCARD_PATH
+    repo_type = "space"
+
+
 def _detect_line_ending(content: str) -> Literal["\r", "\n", "\r\n", None]:  # noqa: F722
     """Detect the line ending of a string. Used by RepoCard to avoid making huge diff on newlines.
 
@@ -787,18 +794,13 @@ def metadata_update(
                         card.data.eval_results.append(new_result)
         else:
             # Any metadata that is not a result metric
-            if (
-                hasattr(card.data, key)
-                and getattr(card.data, key) is not None
-                and not overwrite
-                and getattr(card.data, key) != value
-            ):
+            if card.data.get(key) is not None and not overwrite and card.data.get(key) != value:
                 raise ValueError(
                     f"You passed a new value for the existing meta data field '{key}'."
                     " Set `overwrite=True` to overwrite existing metadata."
                 )
             else:
-                setattr(card.data, key, value)
+                card.data[key] = value
 
     return card.push_to_hub(
         repo_id,
diff --git a/src/huggingface_hub/repocard_data.py b/src/huggingface_hub/repocard_data.py
index 68bfbceb..4f53a85f 100644
--- a/src/huggingface_hub/repocard_data.py
+++ b/src/huggingface_hub/repocard_data.py
@@ -149,6 +149,15 @@ class EvalResult:
 
 @dataclass
 class CardData:
+    """Structure containing metadata from a RepoCard.
+
+    [`CardData`] is the parent class of [`ModelCardData`] and [`DatasetCardData`].
+
+    Metadata can be exported as a dictionary or YAML. Export can be customized to alter the representation of the data
+    (example: flatten evaluation results). `CardData` behaves as a dictionary (can get, pop, set values) but do not
+    inherit from `dict` to allow this export step.
+    """
+
     def __init__(self, **kwargs):
         self.__dict__.update(kwargs)
 
@@ -187,6 +196,26 @@ class CardData:
     def __repr__(self):
         return self.to_yaml()
 
+    def get(self, key: str, default: Any = None) -> Any:
+        """Get value for a given metadata key."""
+        return self.__dict__.get(key, default)
+
+    def pop(self, key: str, default: Any = None) -> Any:
+        """Pop value for a given metadata key."""
+        return self.__dict__.pop(key, default)
+
+    def __getitem__(self, key: str) -> Any:
+        """Get value for a given metadata key."""
+        return self.__dict__[key]
+
+    def __setitem__(self, key: str, value: Any) -> None:
+        """Set value for a given metadata key."""
+        self.__dict__[key] = value
+
+    def __contains__(self, key: str) -> bool:
+        """Check if a given metadata key is set."""
+        return key in self.__dict__
+
 
 class ModelCardData(CardData):
     """Model Card Metadata that is used by Hugging Face Hub when included at the top of your README.md
@@ -362,6 +391,83 @@ class DatasetCardData(CardData):
         data_dict["train-eval-index"] = data_dict.pop("train_eval_index")
 
 
+class SpaceCardData(CardData):
+    """Space Card Metadata that is used by Hugging Face Hub when included at the top of your README.md
+
+    To get an exhaustive reference of Spaces configuration, please visit https://huggingface.co/docs/hub/spaces-config-reference#spaces-configuration-reference.
+
+    Args:
+        title (`str`, *optional*)
+            Title of the Space.
+        sdk (`str`, *optional*)
+            SDK of the Space (one of `gradio`, `streamlit`, `docker`, or `static`).
+        sdk_version (`str`, *optional*)
+            Version of the used SDK (if Gradio/Streamlit sdk).
+        python_version (`str`, *optional*)
+            Python version used in the Space (if Gradio/Streamlit sdk).
+        app_file (`str`, *optional*)
+            Path to your main application file (which contains either gradio or streamlit Python code, or static html code).
+            Path is relative to the root of the repository.
+        app_port (`str`, *optional*)
+            Port on which your application is running. Used only if sdk is `docker`.
+        license (`str`, *optional*)
+            License of this model. Example: apache-2.0 or any license from
+            https://huggingface.co/docs/hub/repositories-licenses.
+        duplicated_from (`str`, *optional*)
+            ID of the original Space if this is a duplicated Space.
+        models (List[`str`], *optional*)
+            List of models related to this Space. Should be a dataset ID found on https://hf.co/models.
+        datasets (`List[str]`, *optional*)
+            List of datasets related to this Space. Should be a dataset ID found on https://hf.co/datasets.
+        tags (`List[str]`, *optional*)
+            List of tags to add to your Space that can be used when filtering on the Hub.
+        kwargs (`dict`, *optional*):
+            Additional metadata that will be added to the space card.
+
+    Example:
+        ```python
+        >>> from huggingface_hub import SpaceCardData
+        >>> card_data = SpaceCardData(
+        ...     title="Dreambooth Training",
+        ...     license="mit",
+        ...     sdk="gradio",
+        ...     duplicated_from="multimodalart/dreambooth-training"
+        ... )
+        >>> card_data.to_dict()
+        {'title': 'Dreambooth Training', 'sdk': 'gradio', 'license': 'mit', 'duplicated_from': 'multimodalart/dreambooth-training'}
+        ```
+    """
+
+    def __init__(
+        self,
+        *,
+        title: Optional[str] = None,
+        sdk: Optional[str] = None,
+        sdk_version: Optional[str] = None,
+        python_version: Optional[str] = None,
+        app_file: Optional[str] = None,
+        app_port: Optional[int] = None,
+        license: Optional[str] = None,
+        duplicated_from: Optional[str] = None,
+        models: Optional[List[str]] = None,
+        datasets: Optional[List[str]] = None,
+        tags: Optional[List[str]] = None,
+        **kwargs,
+    ):
+        self.title = title
+        self.sdk = sdk
+        self.sdk_version = sdk_version
+        self.python_version = python_version
+        self.app_file = app_file
+        self.app_port = app_port
+        self.license = license
+        self.duplicated_from = duplicated_from
+        self.models = models
+        self.datasets = datasets
+        self.tags = tags
+        super().__init__(**kwargs)
+
+
 def model_index_to_eval_results(model_index: List[Dict[str, Any]]) -> Tuple[str, List[EvalResult]]:
     """Takes in a model index and returns the model name and a list of `huggingface_hub.EvalResult` objects.
 


Test patch:
diff --git a/tests/test_repocard.py b/tests/test_repocard.py
index 5730805c..b26830d5 100644
--- a/tests/test_repocard.py
+++ b/tests/test_repocard.py
@@ -29,6 +29,9 @@ from huggingface_hub import (
     EvalResult,
     ModelCard,
     ModelCardData,
+    RepoCard,
+    SpaceCard,
+    SpaceCardData,
     metadata_eval_result,
     metadata_load,
     metadata_save,
@@ -37,7 +40,7 @@ from huggingface_hub import (
 from huggingface_hub.constants import REPOCARD_NAME
 from huggingface_hub.file_download import hf_hub_download
 from huggingface_hub.hf_api import HfApi
-from huggingface_hub.repocard import REGEX_YAML_BLOCK, RepoCard
+from huggingface_hub.repocard import REGEX_YAML_BLOCK
 from huggingface_hub.repocard_data import CardData
 from huggingface_hub.repository import Repository
 from huggingface_hub.utils import SoftTemporaryDirectory, is_jinja_available, logging
@@ -53,6 +56,7 @@ from .testing_utils import (
     repo_name,
     retry_endpoint,
     rmtree_with_retry,
+    with_production_testing,
 )
 
 
@@ -945,3 +949,13 @@ class DatasetCardTest(TestCaseWithCapLog):
 
         # some_data is at the bottom of the template, so should end with whatever we passed to it
         self.assertTrue(card.text.strip().endswith("asdf"))
+
+
+@with_production_testing
+class SpaceCardTest(TestCaseWithCapLog):
+    def test_load_spacecard_from_hub(self) -> None:
+        card = SpaceCard.load("multimodalart/dreambooth-training")
+        self.assertIsInstance(card, SpaceCard)
+        self.assertIsInstance(card.data, SpaceCardData)
+        self.assertEqual(card.data.title, "Dreambooth Training")
+        self.assertIsNone(card.data.app_port)
diff --git a/tests/test_repocard_data.py b/tests/test_repocard_data.py
index c1e8894a..23defbc2 100644
--- a/tests/test_repocard_data.py
+++ b/tests/test_repocard_data.py
@@ -3,7 +3,9 @@ import unittest
 import pytest
 import yaml
 
+from huggingface_hub import SpaceCardData
 from huggingface_hub.repocard_data import (
+    CardData,
     DatasetCardData,
     EvalResult,
     ModelCardData,
@@ -37,6 +39,33 @@ model-index:
 """
 
 
+class BaseCardDataTest(unittest.TestCase):
+    def test_metadata_behave_as_dict(self):
+        metadata = CardData(foo="bar")
+
+        # .get and __getitem__
+        self.assertEqual(metadata.get("foo"), "bar")
+        self.assertEqual(metadata.get("FOO"), None)  # case sensitive
+        self.assertEqual(metadata["foo"], "bar")
+        with self.assertRaises(KeyError):  # case sensitive
+            _ = metadata["FOO"]
+
+        # __setitem__
+        metadata["foo"] = "BAR"
+        self.assertEqual(metadata.get("foo"), "BAR")
+        self.assertEqual(metadata["foo"], "BAR")
+
+        # __contains__
+        self.assertTrue("foo" in metadata)
+        self.assertFalse("FOO" in metadata)
+
+        # export
+        self.assertEqual(str(metadata), "foo: BAR")
+
+        # .pop
+        self.assertEqual(metadata.pop("foo"), "BAR")
+
+
 class ModelCardDataTest(unittest.TestCase):
     def test_eval_results_to_model_index(self):
         expected_results = yaml.safe_load(DUMMY_METADATA_WITH_MODEL_INDEX)
@@ -143,7 +172,7 @@ class ModelCardDataTest(unittest.TestCase):
         self.assertEqual(model_index[0]["name"], "my-cool-model")
         self.assertEqual(model_index[0]["results"][0]["task"]["type"], "image-classification")
 
-    def test_abitrary_incoming_card_data(self):
+    def test_arbitrary_incoming_card_data(self):
         data = ModelCardData(
             model_name="my-cool-model",
             eval_results=[
@@ -195,3 +224,23 @@ class DatasetCardDataTest(unittest.TestCase):
         self.assertTrue(card_data.to_dict().get("train_eval_index") is None)
         # And train-eval-index should be in the dict
         self.assertEqual(card_data.to_dict()["train-eval-index"], train_eval_index)
+
+
+class SpaceCardDataTest(unittest.TestCase):
+    def test_space_card_data(self) -> None:
+        card_data = SpaceCardData(
+            title="Dreambooth Training",
+            license="mit",
+            sdk="gradio",
+            duplicated_from="multimodalart/dreambooth-training",
+        )
+        self.assertEqual(
+            card_data.to_dict(),
+            {
+                "title": "Dreambooth Training",
+                "sdk": "gradio",
+                "license": "mit",
+                "duplicated_from": "multimodalart/dreambooth-training",
+            },
+        )
+        self.assertIsNone(card_data.tags)  # SpaceCardData has some default attributes