Repository: huggingface/huggingface_hub (python)

Issue:
[CLI] Support tag management (create, list, delete)
Requested in https://github.com/huggingface/huggingface_hub/issues/960#issuecomment-2013533386.

Similar to https://github.com/huggingface/huggingface_hub/issues/1655 (branch management from CLI)

Pull request description:
CLI Tag Functionality 
Resolves #2141 

Since I wasn't sure whether to add unit tests about functionalities directly, I have only added parsing tests. Depending on the reviews, I can add tests using the token from constants. 

I was also unsure how to handle the token argument `tag.py:120`; please verify whether this is the proper way to do so. 

@Wauplin 

Patch:
diff --git a/docs/source/en/guides/cli.md b/docs/source/en/guides/cli.md
index 5ce3b8d2..377c5107 100644
--- a/docs/source/en/guides/cli.md
+++ b/docs/source/en/guides/cli.md
@@ -27,7 +27,7 @@ Once installed, you can check that the CLI is correctly setup:
 usage: huggingface-cli <command> [<args>]
 
 positional arguments:
-  {env,login,whoami,logout,repo,upload,download,lfs-enable-largefiles,lfs-multipart-upload,scan-cache,delete-cache}
+  {env,login,whoami,logout,repo,upload,download,lfs-enable-largefiles,lfs-multipart-upload,scan-cache,delete-cache,tag}
                         huggingface-cli command helpers
     env                 Print information about the environment.
     login               Log in using a token from huggingface.co/settings/tokens
@@ -40,6 +40,7 @@ positional arguments:
                         Configure your repository to enable upload of files > 5GB.
     scan-cache          Scan cache directory.
     delete-cache        Delete revisions from the cache directory.
+    tag                 (create, list, delete) tags for a repo in the hub
 
 options:
   -h, --help            show this help message and exit
@@ -451,6 +452,68 @@ For more details about how to scan your cache directory, please refer to the [Ma
 
 `huggingface-cli delete-cache` is a tool that helps you delete parts of your cache that you don't use anymore. This is useful for saving and freeing disk space. To learn more about using this command, please refer to the [Manage your cache](./manage-cache#clean-cache-from-the-terminal) guide.
 
+## huggingface-cli tag
+
+The `huggingface-cli tag` command allows you to tag, untag, and list tags for repositories.
+
+### Tag a model
+
+To tag a repo, you need to provide the `repo_id` and the `tag` name:
+
+```bash
+>>> huggingface-cli tag Wauplin/my-cool-model v1.0
+You are about to create tag v1.0 on model Wauplin/my-cool-model
+Tag v1.0 created on Wauplin/my-cool-model
+```
+
+### Tag a model at a specific revision
+
+If you want to tag a specific revision, you can use the `--revision` option. By default, the tag will be created on the `main` branch:
+
+```bash
+>>> huggingface-cli tag Wauplin/my-cool-model v1.0 --revision refs/pr/104
+You are about to create tag v1.0 on model Wauplin/my-cool-model
+Tag v1.0 created on Wauplin/my-cool-model
+```
+
+### Tag a dataset or a Space
+
+If you want to tag a dataset or Space, you must specify the `--repo-type` option:
+
+```bash
+>>> huggingface-cli tag bigcode/the-stack v1.0 --repo-type dataset
+You are about to create tag v1.0 on dataset bigcode/the-stack
+Tag v1.0 created on bigcode/the-stack
+```
+
+### List tags
+
+To list all tags for a repository, use the `-l` or `--list` option:
+
+```bash
+>>> huggingface-cli tag Wauplin/gradio-space-ci -l --repo-type space
+Tags for space Wauplin/gradio-space-ci:
+0.2.2
+0.2.1
+0.2.0
+0.1.2
+0.0.2
+0.0.1
+```
+
+### Delete a tag
+
+To delete a tag, use the `-d` or `--delete` option:
+
+```bash
+>>> huggingface-cli tag -d Wauplin/my-cool-model v1.0
+You are about to delete tag v1.0 on model Wauplin/my-cool-model
+Proceed? [Y/n] y
+Tag v1.0 deleted on Wauplin/my-cool-model
+```
+
+You can also pass `-y` to skip the confirmation step.
+
 ## huggingface-cli env
 
 The `huggingface-cli env` command prints details about your machine setup. This is useful when you open an issue on [GitHub](https://github.com/huggingface/huggingface_hub) to help the maintainers investigate your problem.
diff --git a/src/huggingface_hub/commands/huggingface_cli.py b/src/huggingface_hub/commands/huggingface_cli.py
index 39b6dfe4..fc253c3b 100644
--- a/src/huggingface_hub/commands/huggingface_cli.py
+++ b/src/huggingface_hub/commands/huggingface_cli.py
@@ -20,6 +20,7 @@ from huggingface_hub.commands.download import DownloadCommand
 from huggingface_hub.commands.env import EnvironmentCommand
 from huggingface_hub.commands.lfs import LfsCommands
 from huggingface_hub.commands.scan_cache import ScanCacheCommand
+from huggingface_hub.commands.tag import TagCommands
 from huggingface_hub.commands.upload import UploadCommand
 from huggingface_hub.commands.user import UserCommands
 
@@ -36,6 +37,7 @@ def main():
     LfsCommands.register_subcommand(commands_parser)
     ScanCacheCommand.register_subcommand(commands_parser)
     DeleteCacheCommand.register_subcommand(commands_parser)
+    TagCommands.register_subcommand(commands_parser)
 
     # Let's go
     args = parser.parse_args()
diff --git a/src/huggingface_hub/commands/tag.py b/src/huggingface_hub/commands/tag.py
new file mode 100644
index 00000000..7c6e9b2b
--- /dev/null
+++ b/src/huggingface_hub/commands/tag.py
@@ -0,0 +1,159 @@
+# coding=utf-8
+# Copyright 2024-present, the HuggingFace Inc. team.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Contains commands to perform tag management with the CLI.
+
+Usage Examples:
+    - Create a tag:
+        $ huggingface-cli tag user/my-model 1.0 --message "First release"
+        $ huggingface-cli tag user/my-model 1.0 -m "First release" --revision develop
+        $ huggingface-cli tag user/my-dataset 1.0 -m "First release" --repo-type dataset
+        $ huggingface-cli tag user/my-space 1.0
+    - List all tags:
+        $ huggingface-cli tag -l user/my-model
+        $ huggingface-cli tag --list user/my-dataset --repo-type dataset
+    - Delete a tag:
+        $ huggingface-cli tag -d user/my-model 1.0
+        $ huggingface-cli tag --delete user/my-dataset 1.0 --repo-type dataset
+        $ huggingface-cli tag -d user/my-space 1.0 -y
+"""
+
+from argparse import Namespace, _SubParsersAction
+
+from requests.exceptions import HTTPError
+
+from huggingface_hub.commands import BaseHuggingfaceCLICommand
+from huggingface_hub.constants import (
+    REPO_TYPES,
+)
+from huggingface_hub.hf_api import HfApi
+
+from ..utils import HfHubHTTPError, RepositoryNotFoundError, RevisionNotFoundError
+from ._cli_utils import ANSI
+
+
+class TagCommands(BaseHuggingfaceCLICommand):
+    @staticmethod
+    def register_subcommand(parser: _SubParsersAction):
+        tag_parser = parser.add_parser("tag", help="(create, list, delete) tags for a repo in the hub")
+
+        tag_parser.add_argument("repo_id", type=str, help="The ID of the repo to tag (e.g. `username/repo-name`).")
+        tag_parser.add_argument("tag", nargs="?", type=str, help="The name of the tag for creation or deletion.")
+        tag_parser.add_argument("-m", "--message", type=str, help="The description of the tag to create.")
+        tag_parser.add_argument("--revision", type=str, help="The git revision to tag.")
+        tag_parser.add_argument(
+            "--token", type=str, help="A User Access Token generated from https://huggingface.co/settings/tokens."
+        )
+        tag_parser.add_argument(
+            "--repo-type",
+            choices=["model", "dataset", "space"],
+            default="model",
+            help="Set the type of repository (model, dataset, or space).",
+        )
+        tag_parser.add_argument("-y", "--yes", action="store_true", help="Answer Yes to prompts automatically.")
+
+        tag_parser.add_argument("-l", "--list", action="store_true", help="List tags for a repository.")
+        tag_parser.add_argument("-d", "--delete", action="store_true", help="Delete a tag for a repository.")
+
+        tag_parser.set_defaults(func=lambda args: handle_commands(args))
+
+
+def handle_commands(args: Namespace):
+    if args.list:
+        return TagListCommand(args)
+    elif args.delete:
+        return TagDeleteCommand(args)
+    else:
+        return TagCreateCommand(args)
+
+
+class TagCommand:
+    def __init__(self, args: Namespace):
+        self.args = args
+        self.api = HfApi(token=self.args.token)
+        self.repo_id = self.args.repo_id
+        self.repo_type = self.args.repo_type
+        if self.repo_type not in REPO_TYPES:
+            print("Invalid repo --repo-type")
+            exit(1)
+
+
+class TagCreateCommand(TagCommand):
+    def run(self):
+        print(f"You are about to create tag {ANSI.bold(self.args.tag)} on {self.repo_type} {ANSI.bold(self.repo_id)}")
+
+        try:
+            self.api.create_tag(
+                repo_id=self.repo_id,
+                tag=self.args.tag,
+                tag_message=self.args.message,
+                revision=self.args.revision,
+                repo_type=self.repo_type,
+            )
+        except RepositoryNotFoundError:
+            print(f"{self.repo_type.capitalize()} {ANSI.bold(self.repo_id)} not found.")
+            exit(1)
+        except RevisionNotFoundError:
+            print(f"Revision {ANSI.bold(self.args.revision)} not found.")
+            exit(1)
+        except HfHubHTTPError as e:
+            if e.response.status_code == 409:
+                print(f"Tag {ANSI.bold(self.args.tag)} already exists on {ANSI.bold(self.repo_id)}")
+                exit(1)
+            raise e
+
+        print(f"Tag {ANSI.bold(self.args.tag)} created on {ANSI.bold(self.repo_id)}")
+
+
+class TagListCommand(TagCommand):
+    def run(self):
+        try:
+            refs = self.api.list_repo_refs(
+                repo_id=self.repo_id,
+                repo_type=self.repo_type,
+            )
+        except RepositoryNotFoundError:
+            print(f"{self.repo_type.capitalize()} {ANSI.bold(self.repo_id)} not found.")
+            exit(1)
+        except HTTPError as e:
+            print(e)
+            print(ANSI.red(e.response.text))
+            exit(1)
+        if len(refs.tags) == 0:
+            print("No tags found")
+            exit(0)
+        print(f"Tags for {self.repo_type} {ANSI.bold(self.repo_id)}:")
+        for tag in refs.tags:
+            print(tag.name)
+
+
+class TagDeleteCommand(TagCommand):
+    def run(self):
+        print(f"You are about to delete tag {ANSI.bold(self.args.tag)} on {self.repo_type} {ANSI.bold(self.repo_id)}")
+
+        if not self.args.yes:
+            choice = input("Proceed? [Y/n] ").lower()
+            if choice not in ("", "y", "yes"):
+                print("Abort")
+                exit()
+        try:
+            self.api.delete_tag(repo_id=self.repo_id, tag=self.args.tag, repo_type=self.repo_type)
+        except RepositoryNotFoundError:
+            print(f"{self.repo_type.capitalize()} {ANSI.bold(self.repo_id)} not found.")
+            exit(1)
+        except RevisionNotFoundError:
+            print(f"Tag {ANSI.bold(self.args.tag)} not found on {ANSI.bold(self.repo_id)}")
+            exit(1)
+        print(f"Tag {ANSI.bold(self.args.tag)} deleted on {ANSI.bold(self.repo_id)}")


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index b0aa4301..03943506 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -10,6 +10,7 @@ from unittest.mock import Mock, patch
 from huggingface_hub.commands.delete_cache import DeleteCacheCommand
 from huggingface_hub.commands.download import DownloadCommand
 from huggingface_hub.commands.scan_cache import ScanCacheCommand
+from huggingface_hub.commands.tag import TagCommands
 from huggingface_hub.commands.upload import UploadCommand
 from huggingface_hub.utils import RevisionNotFoundError, SoftTemporaryDirectory, capture_output
 
@@ -560,6 +561,65 @@ class TestDownloadCommand(unittest.TestCase):
             DownloadCommand(args).run()
 
 
+class TestTagCommands(unittest.TestCase):
+    def setUp(self) -> None:
+        """
+        Set up CLI as in `src/huggingface_hub/commands/huggingface_cli.py`.
+        """
+        self.parser = ArgumentParser("huggingface-cli", usage="huggingface-cli <command> [<args>]")
+        commands_parser = self.parser.add_subparsers()
+        TagCommands.register_subcommand(commands_parser)
+
+    def test_tag_create_basic(self) -> None:
+        args = self.parser.parse_args(["tag", DUMMY_MODEL_ID, "1.0", "-m", "My tag message"])
+        self.assertEqual(args.repo_id, DUMMY_MODEL_ID)
+        self.assertEqual(args.tag, "1.0")
+        self.assertIsNotNone(args.message)
+        self.assertIsNone(args.revision)
+        self.assertIsNone(args.token)
+        self.assertEqual(args.repo_type, "model")
+        self.assertFalse(args.yes)
+
+    def test_tag_create_with_all_options(self) -> None:
+        args = self.parser.parse_args(
+            [
+                "tag",
+                DUMMY_MODEL_ID,
+                "1.0",
+                "--message",
+                "My tag message",
+                "--revision",
+                "v1.0.0",
+                "--token",
+                "my-token",
+                "--repo-type",
+                "dataset",
+                "--yes",
+            ]
+        )
+        self.assertEqual(args.repo_id, DUMMY_MODEL_ID)
+        self.assertEqual(args.tag, "1.0")
+        self.assertEqual(args.message, "My tag message")
+        self.assertEqual(args.revision, "v1.0.0")
+        self.assertEqual(args.token, "my-token")
+        self.assertEqual(args.repo_type, "dataset")
+        self.assertTrue(args.yes)
+
+    def test_tag_list_basic(self) -> None:
+        args = self.parser.parse_args(["tag", "--list", DUMMY_MODEL_ID])
+        self.assertEqual(args.repo_id, DUMMY_MODEL_ID)
+        self.assertIsNone(args.token)
+        self.assertEqual(args.repo_type, "model")
+
+    def test_tag_delete_basic(self) -> None:
+        args = self.parser.parse_args(["tag", "--delete", DUMMY_MODEL_ID, "1.0"])
+        self.assertEqual(args.repo_id, DUMMY_MODEL_ID)
+        self.assertEqual(args.tag, "1.0")
+        self.assertIsNone(args.token)
+        self.assertEqual(args.repo_type, "model")
+        self.assertFalse(args.yes)
+
+
 @contextmanager
 def tmp_current_directory() -> Generator[str, None, None]:
     """Change current directory to a tmp dir and revert back when exiting."""