diff --git a/docs/source/en/guides/cli.md b/docs/source/en/guides/cli.md index 5ce3b8d281..377c510730 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 [] 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 39b6dfe49a..fc253c3b25 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.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 0000000000..7c6e9b2b7a --- /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)}") diff --git a/tests/test_cli.py b/tests/test_cli.py index b0aa4301db..0394350644 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ 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 @@ def test_download_with_ignored_patterns(self, mock: Mock) -> None: 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 []") + 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."""