Repository: huggingface/huggingface_hub
Pull request #1618 (2023-08-22 17:21:23)

Implemented CLI upload functionality
Addressed #1543:

- #1543 

HuggingFaceDocBuilderDev: The docs for this PR live [here](https://moon-ci-docs.huggingface.co/docs/huggingface_hub/pr_1618). All of your documentation changes will be reflected on that endpoint.
Wauplin: Thanks for the great feedback @LysandreJik! I have revisited a bit the API given your comments:
- No more relative path to compute the path_in_repo. Files are always uploaded at root level except if explicitly set differently by the user. It makes things easier to explain/understand as well.
- If the `local_path` is not provided (e.g. `huggingface-cli upload my-cool-model`), we check if `./my-cool-model` is a local file or folder. If it's the case, we upload it. If not, we raise an exception and ask the user to be explicit.

TL;DR: no implicit stuff except when it's _obvious_

I'll merge this as soon as the CI is green :)
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) Report
Patch coverage: **`74.13%`** and project coverage change: **`-0.43%`** :warning:
> Comparison is base [(`9e71350`)](https://app.codecov.io/gh/huggingface/huggingface_hub/commit/9e71350591390a3f0bdf3ee428a25574e41d20d9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) 82.28% compared to head [(`49b43c5`)](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) 81.86%.

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


```diff
@@            Coverage Diff             @@
##             main    #1618      +/-   ##
==========================================
- Coverage   82.28%   81.86%   -0.43%     
==========================================
  Files          61       62       +1     
  Lines        6849     6964     +115     
==========================================
+ Hits         5636     5701      +65     
- Misses       1213     1263      +50     
```


| [Files Changed](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) | Coverage Δ | |
|---|---|---|
| [src/huggingface\_hub/commands/huggingface\_cli.py](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb21tYW5kcy9odWdnaW5nZmFjZV9jbGkucHk=) | `0.00% <0.00%> (ø)` | |
| [src/huggingface\_hub/commands/upload.py](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb21tYW5kcy91cGxvYWQucHk=) | `75.45% <75.45%> (ø)` | |
| [src/huggingface\_hub/commands/download.py](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb21tYW5kcy9kb3dubG9hZC5weQ==) | `97.26% <100.00%> (+0.11%)` | :arrow_up: |

... and [7 files with indirect coverage changes](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/huggingface/huggingface_hub/pull/1618?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/upload.md b/docs/source/en/guides/upload.md
index c897d471..fe2f1307 100644
--- a/docs/source/en/guides/upload.md
+++ b/docs/source/en/guides/upload.md
@@ -106,6 +106,60 @@ but before that, all previous logs on the repo on deleted. All of this in a sing
 ... )
 ```
 
+## Upload from the CLI
+
+You can use the `huggingface-cli upload` command from the terminal to directly upload files to the Hub. Internally
+it uses the same [`upload_file`] and [`upload_folder`] helpers described above.
+
+You can either upload a single file or an entire folder:
+
+```bash
+# Usage:  huggingface-cli upload [repo_id] [local_path] [path_in_repo]
+>>> huggingface-cli upload Wauplin/my-cool-model ./models/model.safetensors model.safetensors
+https://huggingface.co/Wauplin/my-cool-model/blob/main/model.safetensors
+
+>>> huggingface-cli upload Wauplin/my-cool-model ./models .
+https://huggingface.co/Wauplin/my-cool-model/tree/main
+```
+
+`local_path` and `path_in_repo` are optional and can be implicitly inferred. If `local_path` is not set, the tool will
+check if a local folder or file has the same name as the `repo_id`. If that's the case, its content will be uploaded.
+Otherwise, an exception is raised asking the user to explicitly set `local_path`. In any case, if `path_in_repo` is not
+set, files are uploaded at the root of the repo.
+
+```bash
+# Upload file at root
+huggingface-cli upload my-cool-model model.safetensors
+
+# Upload directory at root
+huggingface-cli upload my-cool-model ./models
+
+# Upload `my-cool-model/` directory if it exist, raise otherwise
+huggingface-cli upload my-cool-model
+```
+
+By default, the token saved locally (using `huggingface-cli login`) will be used. If you want to authenticate explicitly,
+use the `--token` option:
+
+```bash
+huggingface-cli upload my-cool-model --token=hf_****
+```
+
+When uploading a folder, you can use the `--include` and `--exclude` arguments to filter the files to upload. You can
+also use `--delete` to delete existing files on the Hub.
+
+```bash
+# Sync local Space with Hub (upload new files except from logs/, delete removed files)
+huggingface-cli upload Wauplin/space-example --repo-type=space --exclude="/logs/*" --delete="*" --commit-message="Sync local Space with Hub"
+```
+
+Finally, you can also schedule a job that will upload your files regularly (see [scheduled uploads](#scheduled-uploads)).
+
+```bash
+# Upload new logs every 10 minutes
+huggingface-cli upload training-model logs/ --every=10
+```
+
 ## Advanced features
 
 In most cases, you won't need more than [`upload_file`] and [`upload_folder`] to upload your files to the Hub.
diff --git a/src/huggingface_hub/commands/download.py b/src/huggingface_hub/commands/download.py
index a4060199..2171857b 100644
--- a/src/huggingface_hub/commands/download.py
+++ b/src/huggingface_hub/commands/download.py
@@ -39,6 +39,7 @@ import warnings
 from argparse import Namespace, _SubParsersAction
 from typing import List, Literal, Optional, Union
 
+from huggingface_hub import logging
 from huggingface_hub._snapshot_download import snapshot_download
 from huggingface_hub.commands import BaseHuggingfaceCLICommand
 from huggingface_hub.file_download import hf_hub_download
@@ -151,7 +152,9 @@ class DownloadCommand(BaseHuggingfaceCLICommand):
                 print(self._download())  # Print path to downloaded files
             enable_progress_bars()
         else:
+            logging.set_verbosity_info()
             print(self._download())  # Print path to downloaded files
+            logging.set_verbosity_warning()
 
     def _download(self) -> str:
         # Warn user if patterns are ignored
diff --git a/src/huggingface_hub/commands/huggingface_cli.py b/src/huggingface_hub/commands/huggingface_cli.py
index b71215fd..39b6dfe4 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.upload import UploadCommand
 from huggingface_hub.commands.user import UserCommands
 
 
@@ -30,10 +31,11 @@ def main():
     # Register commands
     EnvironmentCommand.register_subcommand(commands_parser)
     UserCommands.register_subcommand(commands_parser)
+    UploadCommand.register_subcommand(commands_parser)
+    DownloadCommand.register_subcommand(commands_parser)
     LfsCommands.register_subcommand(commands_parser)
     ScanCacheCommand.register_subcommand(commands_parser)
     DeleteCacheCommand.register_subcommand(commands_parser)
-    DownloadCommand.register_subcommand(commands_parser)
 
     # Let's go
     args = parser.parse_args()
diff --git a/src/huggingface_hub/commands/upload.py b/src/huggingface_hub/commands/upload.py
new file mode 100644
index 00000000..2fa6e704
--- /dev/null
+++ b/src/huggingface_hub/commands/upload.py
@@ -0,0 +1,271 @@
+# coding=utf-8
+# Copyright 2023-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 command to upload a repo or file with the CLI.
+
+Usage:
+    # Upload file (implicit)
+    huggingface-cli upload my-cool-model ./my-cool-model.safetensors
+
+    # Upload file (explicit)
+    huggingface-cli upload my-cool-model ./my-cool-model.safetensors  model.safetensors
+
+    # Upload directory (implicit). If `my-cool-model/` is a directory it will be uploaded, otherwise an exception is raised.
+    huggingface-cli upload my-cool-model
+
+    # Upload directory (explicit)
+    huggingface-cli upload my-cool-model ./models/my-cool-model .
+
+    # Upload filtered directory (example: tensorboard logs except for the last run)
+    huggingface-cli upload my-cool-model ./model/training /logs --include "*.tfevents.*" --exclude "*20230905*"
+
+    # Upload private dataset
+    huggingface-cli upload Wauplin/my-cool-dataset ./data . --repo-type=dataset --private
+
+    # Upload with token
+    huggingface-cli upload Wauplin/my-cool-model --token=hf_****
+
+    # Sync local Space with Hub (upload new files, delete removed files)
+    huggingface-cli upload Wauplin/space-example --repo-type=space --exclude="/logs/*" --delete="*" --commit-message="Sync local Space with Hub"
+
+    # Schedule commits every 30 minutes
+    huggingface-cli upload Wauplin/my-cool-model --every=30
+"""
+import os
+import time
+import warnings
+from argparse import Namespace, _SubParsersAction
+from typing import List, Optional
+
+from huggingface_hub import logging
+from huggingface_hub._commit_scheduler import CommitScheduler
+from huggingface_hub.commands import BaseHuggingfaceCLICommand
+from huggingface_hub.hf_api import create_repo, upload_file, upload_folder
+from huggingface_hub.utils import disable_progress_bars, enable_progress_bars
+
+
+class UploadCommand(BaseHuggingfaceCLICommand):
+    @staticmethod
+    def register_subcommand(parser: _SubParsersAction):
+        upload_parser = parser.add_parser("upload", help="Upload a file or a folder to a repo on the Hub")
+        upload_parser.add_argument(
+            "repo_id", type=str, help="The ID of the repo to upload to (e.g. `username/repo-name`)."
+        )
+        upload_parser.add_argument(
+            "local_path", nargs="?", help="Local path to the file or folder to upload. Defaults to current directory."
+        )
+        upload_parser.add_argument(
+            "path_in_repo",
+            nargs="?",
+            help="Path of the file or folder in the repo. Defaults to the relative path of the file or folder.",
+        )
+        upload_parser.add_argument(
+            "--repo-type",
+            choices=["model", "dataset", "space"],
+            default="model",
+            help="Type of the repo to upload to (e.g. `dataset`).",
+        )
+        upload_parser.add_argument(
+            "--revision",
+            type=str,
+            help="An optional Git revision id which can be a branch name, a tag, or a commit hash.",
+        )
+        upload_parser.add_argument(
+            "--private",
+            action="store_true",
+            help=(
+                "Whether to create a private repo if repo doesn't exist on the Hub. Ignored if the repo already"
+                " exists."
+            ),
+        )
+        upload_parser.add_argument("--include", nargs="*", type=str, help="Glob patterns to match files to upload.")
+        upload_parser.add_argument(
+            "--exclude", nargs="*", type=str, help="Glob patterns to exclude from files to upload."
+        )
+        upload_parser.add_argument(
+            "--delete",
+            nargs="*",
+            type=str,
+            help="Glob patterns for file to be deleted from the repo while committing.",
+        )
+        upload_parser.add_argument(
+            "--commit-message", type=str, help="The summary / title / first line of the generated commit."
+        )
+        upload_parser.add_argument("--commit-description", type=str, help="The description of the generated commit.")
+        upload_parser.add_argument(
+            "--create-pr", action="store_true", help="Whether to upload content as a new Pull Request."
+        )
+        upload_parser.add_argument(
+            "--every",
+            type=float,
+            help="If set, a background job is scheduled to create commits every `every` minutes.",
+        )
+        upload_parser.add_argument(
+            "--token", type=str, help="A User Access Token generated from https://huggingface.co/settings/tokens"
+        )
+        upload_parser.add_argument(
+            "--quiet",
+            action="store_true",
+            help="If True, progress bars are disabled and only the path to the uploaded files is printed.",
+        )
+        upload_parser.set_defaults(func=UploadCommand)
+
+    def __init__(self, args: Namespace) -> None:
+        self.repo_id: str = args.repo_id
+        self.repo_type: Optional[str] = args.repo_type
+        self.revision: Optional[str] = args.revision
+        self.private: bool = args.private
+
+        self.include: Optional[List[str]] = args.include
+        self.exclude: Optional[List[str]] = args.exclude
+        self.delete: Optional[List[str]] = args.delete
+
+        self.commit_message: Optional[str] = args.commit_message
+        self.commit_description: Optional[str] = args.commit_description
+        self.create_pr: bool = args.create_pr
+        self.token: Optional[str] = args.token
+        self.quiet: bool = args.quiet  # disable warnings and progress bars
+
+        # Check `--every` is valid
+        if args.every is not None and args.every <= 0:
+            raise ValueError(f"`every` must be a positive value (got '{args.every}')")
+        self.every: Optional[float] = args.every
+
+        # Resolve `local_path` and `path_in_repo`
+        repo_name: str = args.repo_id.split("/")[-1]  # e.g. "Wauplin/my-cool-model" => "my-cool-model"
+        self.local_path: str
+        self.path_in_repo: str
+        if args.local_path is None and os.path.isfile(repo_name):
+            # Implicit case 1: user provided only a repo_id which happen to be a local file as well => upload it with same name
+            self.local_path = repo_name
+            self.path_in_repo = repo_name
+        elif args.local_path is None and os.path.isdir(repo_name):
+            # Implicit case 2: user provided only a repo_id which happen to be a local folder as well => upload it at root
+            self.local_path = repo_name
+            self.path_in_repo = "."
+        elif args.local_path is None:
+            # Implicit case 3: user provided only a repo_id that does not match a local file or folder
+            # => the user must explicitly provide a local_path => raise exception
+            raise ValueError(f"'{repo_name}' is not a local file or folder. Please set `local_path` explicitly.")
+        elif args.path_in_repo is None and os.path.isfile(args.local_path):
+            # Explicit local path to file, no path in repo => upload it at root with same name
+            self.local_path = args.local_path
+            self.path_in_repo = os.path.basename(args.local_path)
+        elif args.path_in_repo is None:
+            # Explicit local path to folder, no path in repo => upload at root
+            self.local_path = args.local_path
+            self.path_in_repo = "."
+        else:
+            # Finally, if both paths are explicit
+            self.local_path = args.local_path
+            self.path_in_repo = args.path_in_repo
+
+    def run(self) -> None:
+        if self.quiet:
+            disable_progress_bars()
+            with warnings.catch_warnings():
+                warnings.simplefilter("ignore")
+                print(self._upload())
+            enable_progress_bars()
+        else:
+            logging.set_verbosity_info()
+            print(self._upload())
+            logging.set_verbosity_warning()
+
+    def _upload(self) -> str:
+        if os.path.isfile(self.local_path):
+            if self.include is not None and len(self.include) > 0:
+                warnings.warn("Ignoring `--include` since a single file is uploaded.")
+            if self.exclude is not None and len(self.exclude) > 0:
+                warnings.warn("Ignoring `--exclude` since a single file is uploaded.")
+            if self.delete is not None and len(self.delete) > 0:
+                warnings.warn("Ignoring `--delete` since a single file is uploaded.")
+
+        # Schedule commits if `every` is set
+        if self.every is not None:
+            if os.path.isfile(self.local_path):
+                # If file => watch entire folder + use allow_patterns
+                folder_path = os.path.dirname(self.local_path)
+                path_in_repo = (
+                    self.path_in_repo[: -len(self.local_path)]  # remove filename from path_in_repo
+                    if self.path_in_repo.endswith(self.local_path)
+                    else self.path_in_repo
+                )
+                allow_patterns = [self.local_path]
+                ignore_patterns = []
+            else:
+                folder_path = self.local_path
+                path_in_repo = self.path_in_repo
+                allow_patterns = self.include or []
+                ignore_patterns = self.exclude or []
+                if self.delete is not None and len(self.delete) > 0:
+                    warnings.warn("Ignoring `--delete` when uploading with scheduled commits.")
+
+            scheduler = CommitScheduler(
+                folder_path=folder_path,
+                repo_id=self.repo_id,
+                repo_type=self.repo_type,
+                revision=self.revision,
+                allow_patterns=allow_patterns,
+                ignore_patterns=ignore_patterns,
+                path_in_repo=path_in_repo,
+                private=self.private,
+                every=self.every,
+                token=self.token,
+            )
+            print(f"Scheduling commits every {self.every} minutes to {scheduler.repo_id}.")
+            try:  # Block main thread until KeyboardInterrupt
+                while True:
+                    time.sleep(100)
+            except KeyboardInterrupt:
+                scheduler.stop()
+                return "Stopped scheduled commits."
+
+        # Otherwise, create repo and proceed with the upload
+        if not os.path.isfile(self.local_path) and not os.path.isdir(self.local_path):
+            raise FileNotFoundError(f"No such file or directory: '{self.local_path}'.")
+        repo_id = create_repo(
+            repo_id=self.repo_id, repo_type=self.repo_type, exist_ok=True, private=self.private, token=self.token
+        ).repo_id
+
+        # File-based upload
+        if os.path.isfile(self.local_path):
+            return upload_file(
+                path_or_fileobj=self.local_path,
+                path_in_repo=self.path_in_repo,
+                repo_id=repo_id,
+                repo_type=self.repo_type,
+                revision=self.revision,
+                token=self.token,
+                commit_message=self.commit_message,
+                commit_description=self.commit_description,
+                create_pr=self.create_pr,
+            )
+
+        # Folder-based upload
+        else:
+            return upload_folder(
+                folder_path=self.local_path,
+                path_in_repo=self.path_in_repo,
+                repo_id=repo_id,
+                repo_type=self.repo_type,
+                revision=self.revision,
+                token=self.token,
+                commit_message=self.commit_message,
+                commit_description=self.commit_description,
+                create_pr=self.create_pr,
+                allow_patterns=self.include,
+                ignore_patterns=self.exclude,
+                delete_patterns=self.delete,
+            )


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index cef861f6..4117eb86 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,16 +1,19 @@
+import os
 import unittest
 import warnings
 from argparse import ArgumentParser, Namespace
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Generator
 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.utils import capture_output
+from huggingface_hub.commands.upload import UploadCommand
+from huggingface_hub.utils import SoftTemporaryDirectory, capture_output
 
-from .testing_utils import (
-    DUMMY_MODEL_ID,
-)
+from .testing_utils import DUMMY_MODEL_ID
 
 
 class TestCacheCommand(unittest.TestCase):
@@ -58,6 +61,223 @@ class TestCacheCommand(unittest.TestCase):
         self.assertEqual(args.func, DeleteCacheCommand)
 
 
+class TestUploadCommand(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()
+        UploadCommand.register_subcommand(commands_parser)
+
+    def test_upload_basic(self) -> None:
+        """Test `huggingface-cli upload my-folder to dummy-repo`."""
+        cmd = UploadCommand(self.parser.parse_args(["upload", DUMMY_MODEL_ID, "my-folder"]))
+        self.assertEqual(cmd.repo_id, DUMMY_MODEL_ID)
+        self.assertEqual(cmd.local_path, "my-folder")
+        self.assertEqual(cmd.path_in_repo, ".")  # implicit
+        self.assertEqual(cmd.repo_type, "model")
+        self.assertEqual(cmd.revision, None)
+        self.assertEqual(cmd.include, None)
+        self.assertEqual(cmd.exclude, None)
+        self.assertEqual(cmd.delete, None)
+        self.assertEqual(cmd.commit_message, None)
+        self.assertEqual(cmd.commit_description, None)
+        self.assertEqual(cmd.create_pr, False)
+        self.assertEqual(cmd.every, None)
+        self.assertEqual(cmd.token, None)
+        self.assertEqual(cmd.quiet, False)
+
+    def test_upload_with_all_options(self) -> None:
+        """Test `huggingface-cli upload my-file to dummy-repo with all options selected`."""
+        cmd = UploadCommand(
+            self.parser.parse_args(
+                [
+                    "upload",
+                    DUMMY_MODEL_ID,
+                    "my-file",
+                    "data/",
+                    "--repo-type",
+                    "dataset",
+                    "--revision",
+                    "v1.0.0",
+                    "--include",
+                    "*.json",
+                    "*.yaml",
+                    "--exclude",
+                    "*.log",
+                    "*.txt",
+                    "--delete",
+                    "*.config",
+                    "*.secret",
+                    "--commit-message",
+                    "My commit message",
+                    "--commit-description",
+                    "My commit description",
+                    "--create-pr",
+                    "--every",
+                    "5",
+                    "--token",
+                    "my-token",
+                    "--quiet",
+                ]
+            )
+        )
+        self.assertEqual(cmd.repo_id, DUMMY_MODEL_ID)
+        self.assertEqual(cmd.local_path, "my-file")
+        self.assertEqual(cmd.path_in_repo, "data/")
+        self.assertEqual(cmd.repo_type, "dataset")
+        self.assertEqual(cmd.revision, "v1.0.0")
+        self.assertEqual(cmd.include, ["*.json", "*.yaml"])
+        self.assertEqual(cmd.exclude, ["*.log", "*.txt"])
+        self.assertEqual(cmd.delete, ["*.config", "*.secret"])
+        self.assertEqual(cmd.commit_message, "My commit message")
+        self.assertEqual(cmd.commit_description, "My commit description")
+        self.assertEqual(cmd.create_pr, True)
+        self.assertEqual(cmd.every, 5)
+        self.assertEqual(cmd.token, "my-token")
+        self.assertEqual(cmd.quiet, True)
+
+    def test_upload_implicit_local_path_when_folder_exists(self) -> None:
+        with tmp_current_directory() as cache_dir:
+            folder_path = Path(cache_dir) / "my-cool-model"
+            folder_path.mkdir()
+            cmd = UploadCommand(self.parser.parse_args(["upload", "my-cool-model"]))
+
+        # A folder with the same name as the repo exists => upload it at the root of the repo
+        self.assertEqual(cmd.local_path, "my-cool-model")
+        self.assertEqual(cmd.path_in_repo, ".")
+
+    def test_upload_implicit_local_path_when_file_exists(self) -> None:
+        with tmp_current_directory() as cache_dir:
+            folder_path = Path(cache_dir) / "my-cool-model"
+            folder_path.touch()
+            cmd = UploadCommand(self.parser.parse_args(["upload", "my-cool-model"]))
+
+        # A file with the same name as the repo exists => upload it at the root of the repo
+        self.assertEqual(cmd.local_path, "my-cool-model")
+        self.assertEqual(cmd.path_in_repo, "my-cool-model")
+
+    def test_upload_implicit_local_path_when_org_repo(self) -> None:
+        with tmp_current_directory() as cache_dir:
+            folder_path = Path(cache_dir) / "my-cool-model"
+            folder_path.mkdir()
+            cmd = UploadCommand(self.parser.parse_args(["upload", "my-cool-org/my-cool-model"]))
+
+        # A folder with the same name as the repo exists => upload it at the root of the repo
+        self.assertEqual(cmd.local_path, "my-cool-model")
+        self.assertEqual(cmd.path_in_repo, ".")
+
+    def test_upload_implicit_local_path_otherwise(self) -> None:
+        # No folder or file has the same name as the repo => raise exception
+        with self.assertRaises(ValueError):
+            with tmp_current_directory():
+                UploadCommand(self.parser.parse_args(["upload", "my-cool-model"]))
+
+    def test_upload_explicit_local_path_to_folder_implicit_path_in_repo(self) -> None:
+        with tmp_current_directory() as cache_dir:
+            folder_path = Path(cache_dir) / "path" / "to" / "folder"
+            folder_path.mkdir(parents=True, exist_ok=True)
+            cmd = UploadCommand(self.parser.parse_args(["upload", "my-repo", "./path/to/folder"]))
+        self.assertEqual(cmd.local_path, "./path/to/folder")
+        self.assertEqual(cmd.path_in_repo, ".")  # Always upload the folder at the root of the repo
+
+    def test_upload_explicit_local_path_to_file_implicit_path_in_repo(self) -> None:
+        with tmp_current_directory() as cache_dir:
+            file_path = Path(cache_dir) / "path" / "to" / "file.txt"
+            file_path.parent.mkdir(parents=True, exist_ok=True)
+            file_path.touch()
+            cmd = UploadCommand(self.parser.parse_args(["upload", "my-repo", "./path/to/file.txt"]))
+        self.assertEqual(cmd.local_path, "./path/to/file.txt")
+        self.assertEqual(cmd.path_in_repo, "file.txt")  # If a file, upload it at the root of the repo and keep name
+
+    def test_upload_explicit_paths(self) -> None:
+        cmd = UploadCommand(self.parser.parse_args(["upload", "my-repo", "./path/to/folder", "data/"]))
+        self.assertEqual(cmd.local_path, "./path/to/folder")
+        self.assertEqual(cmd.path_in_repo, "data/")
+
+    def test_every_must_be_positive(self) -> None:
+        with self.assertRaises(ValueError):
+            UploadCommand(self.parser.parse_args(["upload", DUMMY_MODEL_ID, ".", "--every", "0"]))
+
+        with self.assertRaises(ValueError):
+            UploadCommand(self.parser.parse_args(["upload", DUMMY_MODEL_ID, ".", "--every", "-10"]))
+
+    def test_every_as_int(self) -> None:
+        cmd = UploadCommand(self.parser.parse_args(["upload", DUMMY_MODEL_ID, ".", "--every", "10"]))
+        self.assertEqual(cmd.every, 10)
+
+    def test_every_as_float(self) -> None:
+        cmd = UploadCommand(self.parser.parse_args(["upload", DUMMY_MODEL_ID, ".", "--every", "0.5"]))
+        self.assertEqual(cmd.every, 0.5)
+
+    @patch("huggingface_hub.commands.upload.upload_folder")
+    @patch("huggingface_hub.commands.upload.create_repo")
+    def test_upload_folder_mock(self, create_mock: Mock, upload_mock: Mock) -> None:
+        with SoftTemporaryDirectory() as cache_dir:
+            cmd = UploadCommand(
+                self.parser.parse_args(
+                    ["upload", "my-model", cache_dir, ".", "--private", "--include", "*.json", "--delete", "*.json"]
+                )
+            )
+            cmd.run()
+
+            create_mock.assert_called_once_with(
+                repo_id="my-model", repo_type="model", exist_ok=True, private=True, token=None
+            )
+            upload_mock.assert_called_once_with(
+                folder_path=cache_dir,
+                path_in_repo=".",
+                repo_id=create_mock.return_value.repo_id,
+                repo_type="model",
+                revision=None,
+                token=None,
+                commit_message=None,
+                commit_description=None,
+                create_pr=False,
+                allow_patterns=["*.json"],
+                ignore_patterns=None,
+                delete_patterns=["*.json"],
+            )
+
+    @patch("huggingface_hub.commands.upload.upload_file")
+    @patch("huggingface_hub.commands.upload.create_repo")
+    def test_upload_file_mock(self, create_mock: Mock, upload_mock: Mock) -> None:
+        with SoftTemporaryDirectory() as cache_dir:
+            file_path = Path(cache_dir) / "file.txt"
+            file_path.write_text("content")
+            cmd = UploadCommand(
+                self.parser.parse_args(
+                    ["upload", "my-dataset", str(file_path), "logs/file.txt", "--repo-type", "dataset", "--create-pr"]
+                )
+            )
+            cmd.run()
+
+            create_mock.assert_called_once_with(
+                repo_id="my-dataset", repo_type="dataset", exist_ok=True, private=False, token=None
+            )
+            upload_mock.assert_called_once_with(
+                path_or_fileobj=str(file_path),
+                path_in_repo="logs/file.txt",
+                repo_id=create_mock.return_value.repo_id,
+                repo_type="dataset",
+                revision=None,
+                token=None,
+                commit_message=None,
+                commit_description=None,
+                create_pr=True,
+            )
+
+    @patch("huggingface_hub.commands.upload.create_repo")
+    def test_upload_missing_path(self, create_mock: Mock) -> None:
+        cmd = UploadCommand(self.parser.parse_args(["upload", "my-model", "/path/to/missing_file", "logs/file.txt"]))
+        with self.assertRaises(FileNotFoundError):
+            cmd.run()  # File/folder does not exist locally
+
+        # Repo creation happens before the check
+        create_mock.assert_not_called()
+
+
 class TestDownloadCommand(unittest.TestCase):
     def setUp(self) -> None:
         """
@@ -278,3 +498,17 @@ class TestDownloadCommand(unittest.TestCase):
             # Taken from https://docs.pytest.org/en/latest/how-to/capture-warnings.html#additional-use-cases-of-warnings-in-tests
             warnings.simplefilter("error")
             DownloadCommand(args).run()
+
+
+@contextmanager
+def tmp_current_directory() -> Generator[str, None, None]:
+    """Change current directory to a tmp dir and revert back when exiting."""
+    with SoftTemporaryDirectory() as tmp_dir:
+        cwd = os.getcwd()
+        os.chdir(tmp_dir)
+        try:
+            yield tmp_dir
+        except:
+            raise
+        finally:
+            os.chdir(cwd)