Repository: huggingface/huggingface_hub
Pull request #2080 (2024-03-01 18:05:53)

Generate modelcard in `ModelHubMixin`
Solves https://github.com/huggingface/huggingface_hub/issues/1768.

Let's generate a basic modelcard when pushing a `ModelHubMixin`/`PyTorchModelHubMixin` to the Hub. User can overwrite the default content by overwriting `_generate_model_card` in inherited class. By default, model will be tagged with `model_hub_mixin` and `pytorch_model_hub_mixin` which will help us monitor how much this is used.

By default, users inheriting from `ModelHubMixin`/`PyTorchModelHubMixin` can provide `tags`, `libray_name`, `repo_docs` and `url_docs`. Will be useful for models like https://huggingface.co/models?search=facebook/hiera. Other than that I do think we should keep the model cards minimal and encourage users to expand it if necesary.

Here is a short example on how it should be used:
```python
>>> import torch
>>> import torch.nn as nn
>>> from huggingface_hub import PyTorchModelHubMixin

# Define library metadata when inheriting from PyTorchModelHubMixin (all fields are optional)
>>> class MyModel(
...         nn.Module,
...         PyTorchModelHubMixin,
...         library_name="keras-nlp",
...         tags=["keras"],
...         repo_url="https://github.com/keras-team/keras-nlp",
...         docs_url="https://keras.io/keras_nlp/",
...     ):
...     def __init__(self, hidden_size: int = 512, vocab_size: int = 30000, output_size: int = 4):
...         super().__init__()
...         self.param = nn.Parameter(torch.rand(hidden_size, vocab_size))
...         self.linear = nn.Linear(output_size, vocab_size)

...     def forward(self, x):
...         return self.linear(x + self.param)

# Create and save model
>>> model = MyModel(hidden_size=128)
>>> model.save_pretrained("path/to/my-awesome-model")

# Model card has been correctly populated
>>> from huggingface_hub import ModelCard
>>> card = ModelCard.load("path/to/my-awesome-model/README.md")
>>> card.data.tags
["keras", "pytorch_model_hub_mixin", "model_hub_mixin"]
>>> card.data.library_name
"keras-nlp"
```

Example upload to: https://huggingface.co/Wauplin/my-awesome-model.

@NielsRogge @LysandreJik @julien-c Let' me know if you have some feedback. I can adapt the logic and the default template before implementing tests and documenting it.

HuggingFaceDocBuilderDev: It looks like you've updated code related to model or dataset cards in this PR.

Some content is duplicated among the following files. Please make sure that everything stays consistent.
  - [src/.../repocard.py](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/repocard.py)
  - [src/.../datasetcard_template.md](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/templates/modelcard_template.md)
  - [src/.../modelcard_template.md](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/templates/modelcard_template.md)
  - [modelcard.md](https://github.com/huggingface/hub-docs/blob/main/modelcard.md) (`hub-docs` repo)
  - [docs/hub/model-cards.md](https://github.com/huggingface/hub-docs/blob/main/docs/hub/model-cards.md) (`hub-docs` repo)
  - [docs/hub/model-card-annotated.md](https://github.com/huggingface/hub-docs/blob/main/docs/hub/model-card-annotated.md) (`hub-docs` repo)
  - [datasetcard.md](https://github.com/huggingface/hub-docs/blob/main/datasetcard.md) (`hub-docs` repo)
  - [docs/hub/datasets-cards.md](https://github.com/huggingface/hub-docs/blob/main/docs/hub/datasets-cards.md) (`hub-docs` repo)
<!-- Created by actions-cool/maintain-one-comment -->
HuggingFaceDocBuilderDev: The docs for this PR live [here](https://moon-ci-docs.huggingface.co/docs/huggingface_hub/pr_2080). All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.
julien-c: > Other than that I do think we should keep the model cards minimal and encourage users to expand it if necesary.

agree
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/2080?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) Report
Attention: Patch coverage is `90.47619%` with `4 lines` in your changes are missing coverage. Please review.
> Project coverage is 82.91%. Comparing base [(`5a40707`)](https://app.codecov.io/gh/huggingface/huggingface_hub/commit/5a4070716d853f1bf265d80eff70633559eeed82?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) to head [(`7d018d2`)](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/2080?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).

| [Files](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/2080?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) | Patch % | Lines |
|---|---|---|
| [src/huggingface\_hub/hub\_mixin.py](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/2080?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9odWJfbWl4aW4ucHk=) | 84.00% | [4 Missing :warning: ](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/2080?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2080      +/-   ##
==========================================
+ Coverage   82.89%   82.91%   +0.02%     
==========================================
  Files         102      102              
  Lines        9505     9540      +35     
==========================================
+ Hits         7879     7910      +31     
- Misses       1626     1630       +4     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/2080?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).


Patch:
diff --git a/docs/source/en/guides/integrations.md b/docs/source/en/guides/integrations.md
index e64bd0b8..78299ec6 100644
--- a/docs/source/en/guides/integrations.md
+++ b/docs/source/en/guides/integrations.md
@@ -143,13 +143,12 @@ your library, you should:
     model. The method must download the relevant files and load them.
 3. You are done!
 
-The advantage of using [`ModelHubMixin`] is that once you take care of the serialization/loading of the files, you
-are ready to go. You don't need to worry about stuff like repo creation, commits, PRs, or revisions. All
-of this is handled by the mixin and is available to your users. The Mixin also ensures that public methods are well
-documented and type annotated.
+The advantage of using [`ModelHubMixin`] is that once you take care of the serialization/loading of the files, you are ready to go. You don't need to worry about stuff like repo creation, commits, PRs, or revisions. All of this is handled by the mixin and is available to your users. The Mixin also ensures that public methods are well documented and type annotated.
 
 As a bonus, [`ModelHubMixin`] handles the model configuration for you. If your `__init__` method expects a `config` input, it will be automatically saved in the repo when calling `save_pretrained` and reloaded correctly by `load_pretrained`. Moreover, if the `config` input parameter is annotated with dataclass type (e.g. `config: Optional[MyConfigClass] = None`), then the `config` value will be correctly deserialized for you. Finally, all jsonable values passed at initialization will be also stored in the config file. This means you don't necessarily have to expect a `config` input to benefit from it. The big advantage of having a `config.json` file in your model repository is that it automatically enables the analytics on the Hub (e.g. the "downloads" count).
 
+Finally, [`ModelHubMixin`] handles generating a model card for you. When inheriting from [`ModelHubMixin`], you can define metadata such as `library_name`, `tags`, `repo_url` and `docs_url`. Those fields will be reused to populate the modelcard of any model that use your class. This is very practical to make all models using your library easily searchable on the Hub and to provide some resource links for users landing on the Hub. If you want to extend the modelcard template, you can override the [`~ModelHubMixin.generate_model_card`] method.
+
 ### A concrete example: PyTorch
 
 A good example of what we saw above is [`PyTorchModelHubMixin`], our integration for the PyTorch framework. This is a ready-to-use integration.
@@ -165,7 +164,15 @@ Here is how any user can load/save a PyTorch model from/to the Hub:
 
 
 # Define your Pytorch model exactly the same way you are used to
->>> class MyModel(nn.Module, PyTorchModelHubMixin): # multiple inheritance
+>>> class MyModel(
+...         nn.Module,
+...         PyTorchModelHubMixin, # multiple inheritance
+...         library_name="keras-nlp",
+...         tags=["keras"],
+...         repo_url="https://github.com/keras-team/keras-nlp",
+...         docs_url="https://keras.io/keras_nlp/",
+...         # ^ optional metadata to generate model card
+...     ):
 ...     def __init__(self, hidden_size: int = 512, vocab_size: int = 30000, output_size: int = 4):
 ...         super().__init__()
 ...         self.param = nn.Parameter(torch.rand(hidden_size, vocab_size))
@@ -191,6 +198,14 @@ Here is how any user can load/save a PyTorch model from/to the Hub:
 >>> model = MyModel.from_pretrained("username/my-awesome-model")
 >>> model.config
 {"hidden_size": 128, "vocab_size": 30000, "output_size": 4}
+
+# Model card has been correctly populated
+>>> from huggingface_hub import ModelCard
+>>> card = ModelCard.load("username/my-awesome-model")
+>>> card.data.tags
+["keras", "pytorch_model_hub_mixin", "model_hub_mixin"]
+>>> card.data.library_name
+"keras-nlp"
 ```
 
 #### Implementation
@@ -279,3 +294,4 @@ ideas on how to handle integration. In any case, feel free to contact us if you
 | Maintenance | More maintenance to add support for configuration, and new features. Might also require fixing issues reported by users. | Less maintenance as most of the interactions with the Hub are implemented in `huggingface_hub`. |
 | Documentation / Type annotation | To be written manually. | Partially handled by `huggingface_hub`. |
 | Download counter | To be handled manually. | Enabled by default if class has a `config` attribute. |
+| Model card | To be handled manually | Generated by default with library_name, tags, etc. |
diff --git a/src/huggingface_hub/hub_mixin.py b/src/huggingface_hub/hub_mixin.py
index 4ce164c8..31c7ace2 100644
--- a/src/huggingface_hub/hub_mixin.py
+++ b/src/huggingface_hub/hub_mixin.py
@@ -1,13 +1,14 @@
 import inspect
 import json
 import os
-from dataclasses import asdict, is_dataclass
+from dataclasses import asdict, dataclass, is_dataclass
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, TypeVar, Union, get_args
 
 from .constants import CONFIG_NAME, PYTORCH_WEIGHTS_NAME, SAFETENSORS_SINGLE_FILE
 from .file_download import hf_hub_download
 from .hf_api import HfApi
+from .repocard import ModelCard, ModelCardData
 from .utils import (
     EntryNotFoundError,
     HfHubHTTPError,
@@ -37,6 +38,26 @@ logger = logging.get_logger(__name__)
 # Generic variable that is either ModelHubMixin or a subclass thereof
 T = TypeVar("T", bound="ModelHubMixin")
 
+DEFAULT_MODEL_CARD = """
+---
+# For reference on model card metadata, see the spec: https://github.com/huggingface/hub-docs/blob/main/modelcard.md?plain=1
+# Doc / guide: https://huggingface.co/docs/hub/model-cards
+{{ card_data }}
+---
+
+This model has been pushed to the Hub using **{{ library_name }}**:
+- Repo: {{ repo_url | default("[More Information Needed]", true) }}
+- Docs: {{ docs_url | default("[More Information Needed]", true) }}
+"""
+
+
+@dataclass
+class LibraryInfo:
+    library_name: Optional[str] = None
+    tags: Optional[List[str]] = None
+    repo_url: Optional[str] = None
+    docs_url: Optional[str] = None
+
 
 class ModelHubMixin:
     """
@@ -51,8 +72,15 @@ class ModelHubMixin:
     ```python
     >>> from huggingface_hub import ModelHubMixin
 
-    # Inherit from ModelHubMixin (and optionally from your framework's model class)
-    >>> class MyCustomModel(ModelHubMixin):
+    # Inherit from ModelHubMixin
+    >>> class MyCustomModel(
+    ...         ModelHubMixin,
+    ...         library_name="my-library",
+    ...         tags=["x-custom-tag"],
+    ...         repo_url="https://github.com/huggingface/my-cool-library",
+    ...         docs_url="https://huggingface.co/docs/my-cool-library",
+    ...         # ^ optional metadata to generate model card
+    ...     ):
     ...     def __init__(self, size: int = 512, device: str = "cpu"):
     ...         # define how to initialize your model
     ...         super().__init__()
@@ -91,18 +119,46 @@ class ModelHubMixin:
     >>> reloaded_model = MyCustomModel.from_pretrained("username/my-awesome-model")
     >>> reloaded_model.config
     {"size": 256, "device": "gpu"}
+
+    # Model card has been correctly populated
+    >>> from huggingface_hub import ModelCard
+    >>> card = ModelCard.load("username/my-awesome-model")
+    >>> card.data.tags
+    ["x-custom-tag", "pytorch_model_hub_mixin", "model_hub_mixin"]
+    >>> card.data.library_name
+    "my-library"
     ```
     """
 
     config: Optional[Union[dict, "DataclassInstance"]] = None
     # ^ optional config attribute automatically set in `from_pretrained` (if not already set by the subclass)
-
+    library_info: LibraryInfo
+    # ^ information about the library integrating ModelHubMixin (used to generate model card)
     _init_parameters: Dict[str, inspect.Parameter]
     _jsonable_default_values: Dict[str, Any]
+    # ^ internal values to handle config
 
-    def __init_subclass__(cls) -> None:
-        """Inspect __init__ signature only once when subclassing."""
+    def __init_subclass__(
+        cls,
+        library_name: Optional[str] = None,
+        tags: Optional[List[str]] = None,
+        repo_url: Optional[str] = None,
+        docs_url: Optional[str] = None,
+    ) -> None:
+        """Inspect __init__ signature only once when subclassing + handle modelcard."""
         super().__init_subclass__()
+
+        # Will be reused when creating modelcard
+        tags = tags or []
+        tags.append("model_hub_mixin")
+        cls.library_info = LibraryInfo(
+            library_name=library_name,
+            tags=tags,
+            repo_url=repo_url,
+            docs_url=docs_url,
+        )
+
+        # Inspect __init__ signature to handle config
         cls._init_parameters = dict(inspect.signature(cls.__init__).parameters)
         cls._jsonable_default_values = {
             param.name: param.default
@@ -198,7 +254,12 @@ class ModelHubMixin:
         if config is not None:
             if is_dataclass(config):
                 config = asdict(config)  # type: ignore[arg-type]
-            (save_directory / CONFIG_NAME).write_text(json.dumps(config, indent=2))
+            (save_directory / CONFIG_NAME).write_text(json.dumps(config, sort_keys=True, indent=2))
+
+        # save model card
+        model_card_path = save_directory / "README.md"
+        if not model_card_path.exists():  # do not overwrite if already exists
+            self.generate_model_card().save(save_directory / "README.md")
 
         # push to the Hub if required
         if push_to_hub:
@@ -317,7 +378,10 @@ class ModelHubMixin:
                 # Forward config to model initialization
                 model_kwargs["config"] = config
 
-            elif any(param.kind == inspect.Parameter.VAR_KEYWORD for param in cls._init_parameters.values()):
+            elif (
+                "kwargs" in cls._init_parameters
+                and cls._init_parameters["kwargs"].kind == inspect.Parameter.VAR_KEYWORD
+            ):
                 # 2. If __init__ accepts **kwargs, let's forward the config as well (as a dict)
                 model_kwargs["config"] = config
 
@@ -465,6 +529,13 @@ class ModelHubMixin:
                 delete_patterns=delete_patterns,
             )
 
+    def generate_model_card(self, *args, **kwargs) -> ModelCard:
+        card = ModelCard.from_template(
+            card_data=ModelCardData(**asdict(self.library_info)),
+            template_str=DEFAULT_MODEL_CARD,
+        )
+        return card
+
 
 class PyTorchModelHubMixin(ModelHubMixin):
     """
@@ -479,7 +550,14 @@ class PyTorchModelHubMixin(ModelHubMixin):
     >>> import torch.nn as nn
     >>> from huggingface_hub import PyTorchModelHubMixin
 
-    >>> class MyModel(nn.Module, PyTorchModelHubMixin):
+    >>> class MyModel(
+    ...         nn.Module,
+    ...         PyTorchModelHubMixin,
+    ...         library_name="keras-nlp",
+    ...         repo_url="https://github.com/keras-team/keras-nlp",
+    ...         docs_url="https://keras.io/keras_nlp/",
+    ...         # ^ optional metadata to generate model card
+    ...     ):
     ...     def __init__(self, hidden_size: int = 512, vocab_size: int = 30000, output_size: int = 4):
     ...         super().__init__()
     ...         self.param = nn.Parameter(torch.rand(hidden_size, vocab_size))
@@ -502,6 +580,12 @@ class PyTorchModelHubMixin(ModelHubMixin):
     ```
     """
 
+    def __init_subclass__(cls, *args, tags: Optional[List[str]] = None, **kwargs) -> None:
+        tags = tags or []
+        tags.append("pytorch_model_hub_mixin")
+        kwargs["tags"] = tags
+        return super().__init_subclass__(*args, **kwargs)
+
     def _save_pretrained(self, save_directory: Path) -> None:
         """Save weights from a Pytorch model to a local directory."""
         model_to_save = self.module if hasattr(self, "module") else self  # type: ignore
diff --git a/src/huggingface_hub/repocard.py b/src/huggingface_hub/repocard.py
index 316ccb8d..2ea7fc13 100644
--- a/src/huggingface_hub/repocard.py
+++ b/src/huggingface_hub/repocard.py
@@ -294,6 +294,7 @@ class RepoCard:
         cls,
         card_data: CardData,
         template_path: Optional[str] = None,
+        template_str: Optional[str] = None,
         **template_kwargs,
     ):
         """Initialize a RepoCard from a template. By default, it uses the default template.
@@ -322,7 +323,12 @@ class RepoCard:
 
         kwargs = card_data.to_dict().copy()
         kwargs.update(template_kwargs)  # Template_kwargs have priority
-        template = jinja2.Template(Path(template_path or cls.default_template_path).read_text())
+
+        if template_path is not None:
+            template_str = Path(template_path).read_text()
+        if template_str is None:
+            template_str = Path(cls.default_template_path).read_text()
+        template = jinja2.Template(template_str)
         content = template.render(card_data=card_data.to_yaml(), **kwargs)
         return cls(content)
 
@@ -337,6 +343,7 @@ class ModelCard(RepoCard):
         cls,
         card_data: ModelCardData,
         template_path: Optional[str] = None,
+        template_str: Optional[str] = None,
         **template_kwargs,
     ):
         """Initialize a ModelCard from a template. By default, it uses the default template, which can be found here:
@@ -404,7 +411,7 @@ class ModelCard(RepoCard):
 
             ```
         """
-        return super().from_template(card_data, template_path, **template_kwargs)
+        return super().from_template(card_data, template_path, template_str, **template_kwargs)
 
 
 class DatasetCard(RepoCard):
@@ -417,6 +424,7 @@ class DatasetCard(RepoCard):
         cls,
         card_data: DatasetCardData,
         template_path: Optional[str] = None,
+        template_str: Optional[str] = None,
         **template_kwargs,
     ):
         """Initialize a DatasetCard from a template. By default, it uses the default template, which can be found here:
@@ -468,7 +476,7 @@ class DatasetCard(RepoCard):
 
             ```
         """
-        return super().from_template(card_data, template_path, **template_kwargs)
+        return super().from_template(card_data, template_path, template_str, **template_kwargs)
 
 
 class SpaceCard(RepoCard):
diff --git a/src/huggingface_hub/repocard_data.py b/src/huggingface_hub/repocard_data.py
index 4bdc52e1..c85c450c 100644
--- a/src/huggingface_hub/repocard_data.py
+++ b/src/huggingface_hub/repocard_data.py
@@ -312,7 +312,7 @@ class ModelCardData(CardData):
         self.language = language
         self.license = license
         self.library_name = library_name
-        self.tags = tags
+        self.tags = _to_unique_list(tags)
         self.base_model = base_model
         self.datasets = datasets
         self.metrics = metrics
@@ -507,7 +507,7 @@ class SpaceCardData(CardData):
         self.duplicated_from = duplicated_from
         self.models = models
         self.datasets = datasets
-        self.tags = tags
+        self.tags = _to_unique_list(tags)
         super().__init__(**kwargs)
 
 
@@ -717,3 +717,13 @@ def eval_results_to_model_index(model_name: str, eval_results: List[EvalResult])
         }
     ]
     return _remove_none(model_index)
+
+
+def _to_unique_list(tags: Optional[List[str]]) -> Optional[List[str]]:
+    if tags is None:
+        return tags
+    unique_tags = []  # make tags unique + keep order explicitly
+    for tag in tags:
+        if tag not in unique_tags:
+            unique_tags.append(tag)
+    return unique_tags


Test patch:
diff --git a/tests/test_hub_mixin_pytorch.py b/tests/test_hub_mixin_pytorch.py
index 9fe623bc..0f0a6404 100644
--- a/tests/test_hub_mixin_pytorch.py
+++ b/tests/test_hub_mixin_pytorch.py
@@ -8,7 +8,7 @@ from unittest.mock import Mock, patch
 
 import pytest
 
-from huggingface_hub import HfApi, hf_hub_download
+from huggingface_hub import HfApi, ModelCard, hf_hub_download
 from huggingface_hub.constants import PYTORCH_WEIGHTS_NAME
 from huggingface_hub.hub_mixin import ModelHubMixin, PyTorchModelHubMixin
 from huggingface_hub.utils import EntryNotFoundError, HfHubHTTPError, SoftTemporaryDirectory, is_torch_available
@@ -34,6 +34,14 @@ if is_torch_available():
         def forward(self, x):
             return self.l1(x)
 
+    class DummyModelWithTags(nn.Module, PyTorchModelHubMixin, tags=["tag1", "tag2"], library_name="my-dummy-lib"):
+        def __init__(self, linear_layer: int = 4):
+            super().__init__()
+            self.l1 = nn.Linear(linear_layer, linear_layer)
+
+        def forward(self, x):
+            return self.l1(x)
+
     class DummyModelNoConfig(nn.Module, PyTorchModelHubMixin):
         def __init__(
             self,
@@ -45,9 +53,9 @@ if is_torch_available():
             self.num_classes = num_classes
             self.state = state
             self.not_jsonable = not_jsonable
-
 else:
     DummyModel = None
+    DummyModelWithTags = None
     DummyModelNoConfig = None
 
 
@@ -66,15 +74,12 @@ class PytorchHubMixinTest(unittest.TestCase):
     def test_save_pretrained_basic(self):
         DummyModel().save_pretrained(self.cache_dir)
         files = os.listdir(self.cache_dir)
-        self.assertTrue("model.safetensors" in files)
-        self.assertEqual(len(files), 1)
+        assert set(files) == {"README.md", "model.safetensors"}
 
     def test_save_pretrained_with_config(self):
         DummyModel().save_pretrained(self.cache_dir, config=CONFIG)
         files = os.listdir(self.cache_dir)
-        self.assertTrue("config.json" in files)
-        self.assertTrue("model.safetensors" in files)
-        self.assertEqual(len(files), 2)
+        assert set(files) == {"README.md", "config.json", "model.safetensors"}
 
     def test_save_as_safetensors(self):
         DummyModel().save_pretrained(self.cache_dir, config=TOKEN)
@@ -250,6 +255,17 @@ class PytorchHubMixinTest(unittest.TestCase):
         # Delete repo
         self._api.delete_repo(repo_id=repo_id)
 
+    def test_generate_model_card(self):
+        model = DummyModelWithTags()
+        card = model.generate_model_card()
+        assert card.data.tags == ["tag1", "tag2", "pytorch_model_hub_mixin", "model_hub_mixin"]
+
+        model.save_pretrained(self.cache_dir)
+        card_reloaded = ModelCard.load(self.cache_dir / "README.md")
+
+        assert str(card) == str(card_reloaded)
+        assert card.data == card_reloaded.data
+
     def test_load_no_config(self):
         config_file = self.cache_dir / "config.json"
 
diff --git a/tests/test_repocard.py b/tests/test_repocard.py
index 67cdd0fa..fe19d7c9 100644
--- a/tests/test_repocard.py
+++ b/tests/test_repocard.py
@@ -178,6 +178,15 @@ DUMMY_MODELCARD_EMPTY_METADATA = """
 Some cool dataset card.
 """
 
+DUMMY_MODEL_CARD_TEMPLATE = """
+---
+{{ card_data }}
+---
+
+Custom template passed as a string.
+{{ repo_url | default("[More Information Needed]", true) }}
+"""
+
 
 def require_jinja(test_case):
     """
@@ -586,7 +595,8 @@ class RepoCardTest(TestCaseWithHfApi):
         )
 
     @require_jinja
-    def test_repo_card_from_custom_template(self):
+    def test_repo_card_from_custom_template_path(self):
+        # Template is passed as a path (not a raw string)
         template_path = SAMPLE_CARDS_DIR / "sample_template.md"
         card = RepoCard.from_template(
             card_data=CardData(
@@ -605,6 +615,15 @@ class RepoCardTest(TestCaseWithHfApi):
             "Custom template didn't set jinja variable correctly",
         )
 
+    @require_jinja
+    def test_repo_card_from_custom_template_string(self):
+        # Template is passed as a raw string (not a path)
+        card = RepoCard.from_template(
+            card_data=CardData(language="en", license="mit"),
+            template_str=DUMMY_MODEL_CARD_TEMPLATE,
+        )
+        assert "Custom template passed as a string." in str(card)
+
     def test_repo_card_data_must_be_dict(self):
         sample_path = SAMPLE_CARDS_DIR / "sample_invalid_card_data.md"
         with pytest.raises(ValueError, match="repo card metadata block should be a dict"):
diff --git a/tests/test_repocard_data.py b/tests/test_repocard_data.py
index 2efd10d1..f1cfe11d 100644
--- a/tests/test_repocard_data.py
+++ b/tests/test_repocard_data.py
@@ -233,6 +233,10 @@ class ModelCardDataTest(unittest.TestCase):
                 source_name="Open LLM Leaderboard",
             )
 
+    def test_model_card_unique_tags(self):
+        data = ModelCardData(tags=["tag2", "tag1", "tag2", "tag3"])
+        assert data.tags == ["tag2", "tag1", "tag3"]
+
 
 class DatasetCardDataTest(unittest.TestCase):
     def test_train_eval_index_keys_updated(self):