Repository: huggingface/huggingface_hub
Pull request #1029 (2022-09-01 14:02:55)

Add validator for repo id + decorator to validate arguments in `huggingface_hub`
Discussed in https://github.com/huggingface/huggingface_hub/issues/1008.

The goal is to avoid receiving repo ids in the form `datasets/user/repo_name` or as a Path and not noticing it (was the root cause of https://github.com/huggingface/transformers/issues/18691 but found out about it only after digging).

In addition, I tried to set up a small structure to harmonize the validation of other arguments that are used everywhere without duplicating the validation (`repo_type` for example). But for now, let's stick to `repo_id` only.


---
**EDIT**: just when opening this PR, I realized the `"datasets/huggingface/label-files"` form for `repo_id` is used ~25 times in the `transformers` library ([here in the code](https://github.com/huggingface/transformers/blob/ab663b227490ee82fe944bbf3680d26fd53b337c/src/transformers/models/beit/convert_beit_unilm_to_pytorch.py#L179) or [here in the examples](https://github.com/huggingface/transformers/blob/ab663b227490ee82fe944bbf3680d26fd53b337c/examples/pytorch/semantic-segmentation/run_semantic_segmentation_no_trainer.py#L390)). It's a bit annoying so I guess we will have to go through a deprecation cycle for that as well. Luckily, the form is used only for calls to `hf_hub_url` and `hf_hub_download`.
My suggestion would be to:
1. remove the `repo_id` validation for `hf_hub_url` and `hf_hub_download` for now
2. add a deprecation warning
3. fix `transformers` in the meantime to avoid "datasets/***" patterns
4. add `repo_id` validation in ~v0.12 to `hf_hub_url` and `hf_hub_download`


HuggingFaceDocBuilderDev: The docs for this PR live [here](https://moon-ci-docs.huggingface.co/docs/huggingface_hub/pr_1029). All of your documentation changes will be reflected on that endpoint.
codecov[bot]: # [Codecov](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) Report
> Merging [#1029](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) (576ee2f) into [main](https://codecov.io/gh/huggingface/huggingface_hub/commit/c03bd2917c35b44d6a38a63dac4b0f72dad3ab99?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) (c03bd29) will **increase** coverage by `0.23%`.
> The diff coverage is `95.45%`.

> :exclamation: Current head 576ee2f differs from pull request most recent head aed8b19. Consider uploading reports for the commit aed8b19 to get more accurate results

```diff
@@            Coverage Diff             @@
##             main    #1029      +/-   ##
==========================================
+ Coverage   82.18%   82.41%   +0.23%     
==========================================
  Files          35       36       +1     
  Lines        3592     3662      +70     
==========================================
+ Hits         2952     3018      +66     
- Misses        640      644       +4     
```


| [Impacted Files](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) | Coverage Δ | |
|---|---|---|
| [src/huggingface\_hub/hf\_api.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9oZl9hcGkucHk=) | `87.21% <66.66%> (+0.36%)` | :arrow_up: |
| [src/huggingface\_hub/\_commit\_api.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9fY29tbWl0X2FwaS5weQ==) | `92.17% <100.00%> (+0.13%)` | :arrow_up: |
| [src/huggingface\_hub/\_snapshot\_download.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9fc25hcHNob3RfZG93bmxvYWQucHk=) | `92.98% <100.00%> (+0.12%)` | :arrow_up: |
| [src/huggingface\_hub/fastai\_utils.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9mYXN0YWlfdXRpbHMucHk=) | `79.80% <100.00%> (+0.39%)` | :arrow_up: |
| [src/huggingface\_hub/file\_download.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9maWxlX2Rvd25sb2FkLnB5) | `85.94% <100.00%> (+0.15%)` | :arrow_up: |
| [src/huggingface\_hub/hub\_mixin.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9odWJfbWl4aW4ucHk=) | `86.48% <100.00%> (-3.33%)` | :arrow_down: |
| [src/huggingface\_hub/inference\_api.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9pbmZlcmVuY2VfYXBpLnB5) | `88.57% <100.00%> (+0.33%)` | :arrow_up: |
| [src/huggingface\_hub/keras\_mixin.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9rZXJhc19taXhpbi5weQ==) | `90.53% <100.00%> (+0.05%)` | :arrow_up: |
| [src/huggingface\_hub/lfs.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9sZnMucHk=) | `74.84% <100.00%> (+0.32%)` | :arrow_up: |
| [src/huggingface\_hub/repocard.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9yZXBvY2FyZC5weQ==) | `95.55% <100.00%> (+0.10%)` | :arrow_up: |
| ... and [4 more](https://codecov.io/gh/huggingface/huggingface_hub/pull/1029/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface)


Patch:
diff --git a/docs/source/package_reference/utilities.mdx b/docs/source/package_reference/utilities.mdx
index 6ca1afad..d66882f9 100644
--- a/docs/source/package_reference/utilities.mdx
+++ b/docs/source/package_reference/utilities.mdx
@@ -26,12 +26,12 @@ The levels should be understood as follows:
 
 - `error`: only show critical logs about usage which may result in an error or unexpected behavior.
 - `warning`: show logs that aren't critical but usage may result in unintended behavior.
-Additionally, important informative logs may be shown.
+  Additionally, important informative logs may be shown.
 - `info`: show most logs, including some verbose logging regarding what is happening under the hood.
-If something is behaving in an unexpected manner, we recommend switching the verbosity level to this in order
-to get more information.
+  If something is behaving in an unexpected manner, we recommend switching the verbosity level to this in order
+  to get more information.
 - `debug`: show all logs, including some internal logs which may be used to track exactly what's happening
-under the hood.
+  under the hood.
 
 [[autodoc]] logging.get_verbosity
 [[autodoc]] logging.set_verbosity
@@ -60,3 +60,54 @@ See below for all custom errors thrown by different methods across the package.
 [[autodoc]] huggingface_hub.utils.EntryNotFoundError
 
 [[autodoc]] huggingface_hub.utils.LocalEntryNotFoundError
+
+## Validators
+
+`huggingface_hub` include custom validators to validate method arguments automatically.
+Validation is inspired by the work done in [Pydantic](https://pydantic-docs.helpmanual.io/)
+to validate type hints but with more limited features.
+
+### Generic decorator
+
+[`~utils.validate_hf_hub_args`] is a generic decorator to encapsulate
+methods that have arguments following `huggingface_hub`'s naming. By default, all
+arguments that has a validator implemented will be validated.
+
+If an input is not valid, a [`~utils.HFValidationError`] is thrown. Only
+the first non-valid value throws an error and stops the validation process.
+
+Usage:
+
+```py
+>>> from huggingface_hub.utils import validate_hf_hub_args
+
+>>> @validate_hf_hub_args
+... def my_cool_method(repo_id: str):
+...     print(repo_id)
+
+>>> my_cool_method(repo_id="valid_repo_id")
+valid_repo_id
+
+>>> my_cool_method("other..repo..id")
+huggingface_hub.utils._validators.HFValidationError: Cannot have -- or .. in repo_id: 'other..repo..id'.
+
+>>> my_cool_method(repo_id="other..repo..id")
+huggingface_hub.utils._validators.HFValidationError: Cannot have -- or .. in repo_id: 'other..repo..id'.
+```
+
+#### validate_hf_hub_args
+
+[[autodoc]] utils.validate_hf_hub_args
+
+#### HFValidationError
+
+[[autodoc]] utils.HFValidationError
+
+### Argument validators
+
+Validators can also be used individually. Here is a list of all arguments that can be
+validated.
+
+#### `repo_id`
+
+[[autodoc]] utils.validate_repo_id
diff --git a/src/huggingface_hub/_commit_api.py b/src/huggingface_hub/_commit_api.py
index d9869c9b..5683cea4 100644
--- a/src/huggingface_hub/_commit_api.py
+++ b/src/huggingface_hub/_commit_api.py
@@ -14,7 +14,7 @@ import requests
 
 from .constants import ENDPOINT
 from .lfs import UploadInfo, _validate_batch_actions, lfs_upload, post_lfs_batch_info
-from .utils import logging
+from .utils import logging, validate_hf_hub_args
 from .utils._errors import _raise_convert_bad_request
 from .utils._typing import Literal
 
@@ -161,6 +161,7 @@ class CommitOperationAdd:
 CommitOperation = Union[CommitOperationAdd, CommitOperationDelete]
 
 
+@validate_hf_hub_args
 def upload_lfs_files(
     *,
     additions: Iterable[CommitOperationAdd],
@@ -316,6 +317,7 @@ def validate_preupload_info(preupload_info: dict):
     return preupload_info
 
 
+@validate_hf_hub_args
 def fetch_upload_modes(
     additions: Iterable[CommitOperationAdd],
     repo_type: str,
diff --git a/src/huggingface_hub/_snapshot_download.py b/src/huggingface_hub/_snapshot_download.py
index 134dbf60..b8978a17 100644
--- a/src/huggingface_hub/_snapshot_download.py
+++ b/src/huggingface_hub/_snapshot_download.py
@@ -5,7 +5,7 @@ from typing import Dict, List, Optional, Union
 from .constants import DEFAULT_REVISION, HUGGINGFACE_HUB_CACHE, REPO_TYPES
 from .file_download import REGEX_COMMIT_HASH, hf_hub_download, repo_folder_name
 from .hf_api import HfApi, HfFolder
-from .utils import filter_repo_objects, logging, tqdm
+from .utils import filter_repo_objects, logging, tqdm, validate_hf_hub_args
 from .utils._deprecation import _deprecate_arguments
 
 
@@ -17,6 +17,7 @@ logger = logging.get_logger(__name__)
     deprecated_args={"allow_regex", "ignore_regex"},
     custom_message="Please use `allow_patterns` and `ignore_patterns` instead.",
 )
+@validate_hf_hub_args
 def snapshot_download(
     repo_id: str,
     *,
diff --git a/src/huggingface_hub/fastai_utils.py b/src/huggingface_hub/fastai_utils.py
index 0eba020d..dd44224c 100644
--- a/src/huggingface_hub/fastai_utils.py
+++ b/src/huggingface_hub/fastai_utils.py
@@ -15,7 +15,8 @@ from huggingface_hub.file_download import (
 )
 from huggingface_hub.hf_api import HfApi, HfFolder
 from huggingface_hub.repository import Repository
-from huggingface_hub.utils import logging
+
+from .utils import logging, validate_hf_hub_args
 
 
 logger = logging.get_logger(__name__)
@@ -305,6 +306,7 @@ def _save_pretrained_fastai(
         )
 
 
+@validate_hf_hub_args
 def from_pretrained_fastai(
     repo_id: str,
     revision: Optional[str] = None,
@@ -349,6 +351,7 @@ def from_pretrained_fastai(
     return load_learner(os.path.join(storage_folder, "model.pkl"))
 
 
+@validate_hf_hub_args
 def push_to_hub_fastai(
     learner,
     repo_id: str,
diff --git a/src/huggingface_hub/file_download.py b/src/huggingface_hub/file_download.py
index c5cf2c38..c78eee98 100644
--- a/src/huggingface_hub/file_download.py
+++ b/src/huggingface_hub/file_download.py
@@ -33,7 +33,7 @@ from .constants import (
     REPO_TYPES_URL_PREFIXES,
 )
 from .hf_api import HfFolder
-from .utils import logging, tqdm
+from .utils import logging, tqdm, validate_hf_hub_args
 from .utils._errors import (
     EntryNotFoundError,
     LocalEntryNotFoundError,
@@ -170,6 +170,7 @@ def get_jinja_version():
 REGEX_COMMIT_HASH = re.compile(r"^[0-9a-f]{40}$")
 
 
+@validate_hf_hub_args
 def hf_hub_url(
     repo_id: str,
     filename: str,
@@ -883,6 +884,7 @@ def _cache_commit_hash_for_specific_revision(
             f.write(commit_hash)
 
 
+@validate_hf_hub_args
 def repo_folder_name(*, repo_id: str, repo_type: str) -> str:
     """Return a serialized version of a hf.co repo name and type, safe for disk storage
     as a single non-nested folder.
@@ -894,6 +896,7 @@ def repo_folder_name(*, repo_id: str, repo_type: str) -> str:
     return REPO_ID_SEPARATOR.join(parts)
 
 
+@validate_hf_hub_args
 def hf_hub_download(
     repo_id: str,
     filename: str,
@@ -1304,6 +1307,7 @@ def hf_hub_download(
     return pointer_path
 
 
+@validate_hf_hub_args
 def try_to_load_from_cache(
     repo_id: str,
     filename: str,
diff --git a/src/huggingface_hub/hf_api.py b/src/huggingface_hub/hf_api.py
index bff07303..472c3b34 100644
--- a/src/huggingface_hub/hf_api.py
+++ b/src/huggingface_hub/hf_api.py
@@ -49,7 +49,7 @@ from .constants import (
     REPO_TYPES_URL_PREFIXES,
     SPACES_SDK_TYPES,
 )
-from .utils import filter_repo_objects, logging, parse_datetime
+from .utils import filter_repo_objects, logging, parse_datetime, validate_hf_hub_args
 from .utils._deprecation import _deprecate_positional_args
 from .utils._errors import (
     _raise_convert_bad_request,
@@ -1203,6 +1203,7 @@ class HfApi:
         d = r.json()
         return [SpaceInfo(**x) for x in d]
 
+    @validate_hf_hub_args
     def model_info(
         self,
         repo_id: str,
@@ -1277,6 +1278,7 @@ class HfApi:
         d = r.json()
         return ModelInfo(**d)
 
+    @validate_hf_hub_args
     def dataset_info(
         self,
         repo_id: str,
@@ -1341,6 +1343,7 @@ class HfApi:
         d = r.json()
         return DatasetInfo(**d)
 
+    @validate_hf_hub_args
     def space_info(
         self,
         repo_id: str,
@@ -1405,6 +1408,7 @@ class HfApi:
         d = r.json()
         return SpaceInfo(**d)
 
+    @validate_hf_hub_args
     def repo_info(
         self,
         repo_id: str,
@@ -1477,6 +1481,7 @@ class HfApi:
         else:
             raise ValueError("Unsupported repo type.")
 
+    @validate_hf_hub_args
     def list_repo_files(
         self,
         repo_id: str,
@@ -1517,6 +1522,7 @@ class HfApi:
         )
         return [f.rfilename for f in repo_info.siblings]
 
+    @validate_hf_hub_args
     @_deprecate_positional_args(version="0.12")
     def create_repo(
         self,
@@ -1647,6 +1653,7 @@ class HfApi:
         d = r.json()
         return d["url"]
 
+    @validate_hf_hub_args
     def delete_repo(
         self,
         repo_id: str = None,
@@ -1738,6 +1745,7 @@ class HfApi:
         )
         _raise_for_status(r)
 
+    @validate_hf_hub_args
     def update_repo_visibility(
         self,
         repo_id: str = None,
@@ -1891,6 +1899,7 @@ class HfApi:
             " completed."
         )
 
+    @validate_hf_hub_args
     def create_commit(
         self,
         repo_id: str,
@@ -2051,6 +2060,7 @@ class HfApi:
         _raise_convert_bad_request(commit_resp, endpoint_name="commit")
         return commit_resp.json().get("pullRequestUrl", None)
 
+    @validate_hf_hub_args
     def upload_file(
         self,
         *,
@@ -2201,6 +2211,7 @@ class HfApi:
         # Similar to `hf_hub_url` but it's "blob" instead of "resolve"
         return f"{self.endpoint}/{repo_id}/blob/{revision}/{path_in_repo}"
 
+    @validate_hf_hub_args
     def upload_folder(
         self,
         *,
@@ -2351,6 +2362,7 @@ class HfApi:
         # Similar to `hf_hub_url` but it's "tree" instead of "resolve"
         return f"{self.endpoint}/{repo_id}/tree/{revision}/{path_in_repo}"
 
+    @validate_hf_hub_args
     def delete_file(
         self,
         path_in_repo: str,
@@ -2472,6 +2484,7 @@ class HfApi:
         else:
             return f"{organization}/{model_id}"
 
+    @validate_hf_hub_args
     def get_repo_discussions(
         self,
         repo_id: str,
@@ -2551,6 +2564,7 @@ class HfApi:
                 )
             page_index = page_index + 1
 
+    @validate_hf_hub_args
     def get_discussion_details(
         self,
         repo_id: str,
@@ -2640,6 +2654,7 @@ class HfApi:
             diff=discussion_details.get("diff"),
         )
 
+    @validate_hf_hub_args
     def create_discussion(
         self,
         repo_id: str,
@@ -2728,6 +2743,7 @@ class HfApi:
             token=token,
         )
 
+    @validate_hf_hub_args
     def create_pull_request(
         self,
         repo_id: str,
@@ -2815,6 +2831,7 @@ class HfApi:
         _raise_for_status(resp)
         return resp
 
+    @validate_hf_hub_args
     def comment_discussion(
         self,
         repo_id: str,
@@ -2890,6 +2907,7 @@ class HfApi:
         )
         return deserialize_event(resp.json()["newMessage"])
 
+    @validate_hf_hub_args
     def rename_discussion(
         self,
         repo_id: str,
@@ -2956,6 +2974,7 @@ class HfApi:
         )
         return deserialize_event(resp.json()["newTitle"])
 
+    @validate_hf_hub_args
     def change_discussion_status(
         self,
         repo_id: str,
@@ -3030,6 +3049,7 @@ class HfApi:
         )
         return deserialize_event(resp.json()["newStatus"])
 
+    @validate_hf_hub_args
     def merge_pull_request(
         self,
         repo_id: str,
@@ -3082,6 +3102,7 @@ class HfApi:
             body={"comment": comment.strip()} if comment and comment.strip() else None,
         )
 
+    @validate_hf_hub_args
     def edit_discussion_comment(
         self,
         repo_id: str,
@@ -3138,6 +3159,7 @@ class HfApi:
         )
         return deserialize_event(resp.json()["updatedComment"])
 
+    @validate_hf_hub_args
     def hide_discussion_comment(
         self,
         repo_id: str,
diff --git a/src/huggingface_hub/hub_mixin.py b/src/huggingface_hub/hub_mixin.py
index a90cd2b4..0f6665cc 100644
--- a/src/huggingface_hub/hub_mixin.py
+++ b/src/huggingface_hub/hub_mixin.py
@@ -11,7 +11,7 @@ from .constants import CONFIG_NAME, PYTORCH_WEIGHTS_NAME
 from .file_download import hf_hub_download, is_torch_available
 from .hf_api import HfApi, HfFolder
 from .repository import Repository
-from .utils import logging
+from .utils import logging, validate_hf_hub_args
 from .utils._deprecation import _deprecate_arguments, _deprecate_positional_args
 
 
@@ -182,8 +182,12 @@ class ModelHubMixin:
         if len(model_id.split("@")) == 2:
             model_id, revision = model_id.split("@")
 
-        if os.path.isdir(model_id) and CONFIG_NAME in os.listdir(model_id):
-            config_file = os.path.join(model_id, CONFIG_NAME)
+        config_file: Optional[str] = None
+        if os.path.isdir(model_id):
+            if CONFIG_NAME in os.listdir(model_id):
+                config_file = os.path.join(model_id, CONFIG_NAME)
+            else:
+                logger.warning(f"{CONFIG_NAME} not found in {Path(model_id).resolve()}")
         else:
             try:
                 config_file = hf_hub_download(
@@ -199,7 +203,6 @@ class ModelHubMixin:
                 )
             except requests.exceptions.RequestException:
                 logger.warning(f"{CONFIG_NAME} not found in HuggingFace Hub")
-                config_file = None
 
         if config_file is not None:
             with open(config_file, "r", encoding="utf-8") as f:
@@ -248,6 +251,7 @@ class ModelHubMixin:
             "skip_lfs_files",
         },
     )
+    @validate_hf_hub_args
     def push_to_hub(
         self,
         # NOTE: deprecated signature that will change in 0.12
diff --git a/src/huggingface_hub/inference_api.py b/src/huggingface_hub/inference_api.py
index 8c367026..f55af742 100644
--- a/src/huggingface_hub/inference_api.py
+++ b/src/huggingface_hub/inference_api.py
@@ -3,7 +3,7 @@ from typing import Dict, List, Optional, Union
 import requests
 
 from .hf_api import HfApi
-from .utils import logging
+from .utils import logging, validate_hf_hub_args
 
 
 logger = logging.get_logger(__name__)
@@ -77,6 +77,7 @@ class InferenceApi:
     ```
     """
 
+    @validate_hf_hub_args
     def __init__(
         self,
         repo_id: str,
diff --git a/src/huggingface_hub/keras_mixin.py b/src/huggingface_hub/keras_mixin.py
index 04141b1a..b8344978 100644
--- a/src/huggingface_hub/keras_mixin.py
+++ b/src/huggingface_hub/keras_mixin.py
@@ -30,7 +30,7 @@ from .hf_api import (
     _prepare_upload_folder_commit,
 )
 from .repository import Repository
-from .utils import logging
+from .utils import logging, validate_hf_hub_args
 from .utils._deprecation import _deprecate_arguments, _deprecate_positional_args
 
 
@@ -306,6 +306,7 @@ def from_pretrained_keras(*args, **kwargs):
         "git_email",
     },
 )
+@validate_hf_hub_args
 def push_to_hub_keras(
     # NOTE: deprecated signature that will change in 0.12
     model,
diff --git a/src/huggingface_hub/lfs.py b/src/huggingface_hub/lfs.py
index d0ce18c7..dc72ab81 100644
--- a/src/huggingface_hub/lfs.py
+++ b/src/huggingface_hub/lfs.py
@@ -29,6 +29,7 @@ import requests
 from huggingface_hub.constants import ENDPOINT, REPO_TYPES_URL_PREFIXES
 from requests.auth import HTTPBasicAuth
 
+from .utils import validate_hf_hub_args
 from .utils.sha import sha256, sha_fileobj
 
 
@@ -149,6 +150,7 @@ def _validate_batch_error(lfs_batch_error: dict):
     return lfs_batch_error
 
 
+@validate_hf_hub_args
 def post_lfs_batch_info(
     upload_infos: Iterable[UploadInfo],
     token: str,
@@ -250,11 +252,11 @@ def lfs_upload(
 
     Returns:
         `requests.Response`:
-            the repsonse from the completion request in case of amulti-part upload, and the
+            the response from the completion request in case of a multi-part upload, and the
             response from the single PUT request in case of a single-part upload
 
     Raises:
-        `ValueError`: if some objects / repsonses are malformed
+        `ValueError`: if some objects / responses are malformed
 
         `requests.HTTPError`
     """
diff --git a/src/huggingface_hub/repocard.py b/src/huggingface_hub/repocard.py
index 70b6adb7..76e86dbd 100644
--- a/src/huggingface_hub/repocard.py
+++ b/src/huggingface_hub/repocard.py
@@ -25,6 +25,7 @@ from huggingface_hub.repocard_data import (
 )
 
 from .constants import REPOCARD_NAME
+from .utils import validate_hf_hub_args
 from .utils.logging import get_logger
 
 
@@ -653,6 +654,7 @@ def metadata_eval_result(
     }
 
 
+@validate_hf_hub_args
 def metadata_update(
     repo_id: str,
     metadata: Dict,
diff --git a/src/huggingface_hub/utils/__init__.py b/src/huggingface_hub/utils/__init__.py
index 44e168d2..d3499907 100644
--- a/src/huggingface_hub/utils/__init__.py
+++ b/src/huggingface_hub/utils/__init__.py
@@ -33,6 +33,7 @@ from ._errors import (
 )
 from ._paths import filter_repo_objects
 from ._subprocess import run_subprocess
+from ._validators import HFValidationError, validate_hf_hub_args, validate_repo_id
 from .tqdm import (
     are_progress_bars_disabled,
     disable_progress_bars,
diff --git a/src/huggingface_hub/utils/_validators.py b/src/huggingface_hub/utils/_validators.py
new file mode 100644
index 00000000..557bcb09
--- /dev/null
+++ b/src/huggingface_hub/utils/_validators.py
@@ -0,0 +1,148 @@
+# coding=utf-8
+# Copyright 2022-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 utilities to validate argument values in `huggingface_hub`."""
+import inspect
+import re
+from functools import wraps
+from itertools import chain
+from typing import Callable
+
+
+REPO_ID_REGEX = re.compile(
+    r"""
+    ^
+    (\b[\w\-.]+\b/)? # optional namespace (username or organization)
+    \b               # starts with a word boundary
+    [\w\-.]{1,96}    # repo_name: alphanumeric + . _ -
+    \b               # ends with a word boundary
+    $
+    """,
+    flags=re.VERBOSE,
+)
+
+
+class HFValidationError(ValueError):
+    """Generic exception thrown by `huggingface_hub` validators.
+
+    Inherits from [`ValueError`](https://docs.python.org/3/library/exceptions.html#ValueError).
+    """
+
+
+def validate_hf_hub_args(fn: Callable) -> Callable:
+    """Validate values received as argument for any public method of `huggingface_hub`.
+
+    The goal of this decorator is to harmonize validation of arguments reused
+    everywhere. By default, all defined validators are tested.
+
+    Validators:
+        - [`~utils.validate_repo_id`]: `repo_id` must be `"repo_name"`
+          or `"namespace/repo_name"`. Namespace is a username or an organization.
+
+    Example:
+    ```py
+    >>> from huggingface_hub.utils import validate_hf_hub_args
+
+    >>> @validate_hf_hub_args
+    ... def my_cool_method(repo_id: str):
+    ...     print(repo_id)
+
+    >>> my_cool_method(repo_id="valid_repo_id")
+    valid_repo_id
+
+    >>> my_cool_method("other..repo..id")
+    huggingface_hub.utils._validators.HFValidationError: Cannot have -- or .. in repo_id: 'other..repo..id'.
+
+    >>> my_cool_method(repo_id="other..repo..id")
+    huggingface_hub.utils._validators.HFValidationError: Cannot have -- or .. in repo_id: 'other..repo..id'.
+    ```
+
+    <Tip warning={true}>
+
+    Raises:
+        - [`~utils.HFValidationError`]: If an input is not valid.
+
+    </Tip>
+    """
+    # TODO: add an argument to opt-out validation for specific argument?
+    signature = inspect.signature(fn)
+
+    @wraps(fn)
+    def _inner_fn(*args, **kwargs):
+        for arg_name, arg_value in chain(
+            zip(signature.parameters, args),  # Args values
+            kwargs.items(),  # Kwargs values
+        ):
+            if arg_name == "repo_id":
+                validate_repo_id(arg_value)
+
+        return fn(*args, **kwargs)
+
+    return _inner_fn
+
+
+def validate_repo_id(repo_id: str) -> None:
+    """Validate `repo_id` is valid.
+
+    This is not meant to replace the proper validation made on the Hub but rather to
+    avoid local inconsistencies whenever possible (example: passing `repo_type` in the
+    `repo_id` is forbidden).
+
+    Rules:
+    - Between 1 and 96 characters.
+    - Either "repo_name" or "namespace/repo_name"
+    - [a-zA-Z0-9] or "-", "_", "."
+    - "--" and ".." are forbidden
+
+    Valid: `"foo"`, `"foo/bar"`, `"123"`, `"Foo-BAR_foo.bar123"`
+
+    Not valid: `"datasets/foo/bar"`, `".repo_id"`, `"foo--bar"`, `"foo.git"`
+
+    Example:
+    ```py
+    >>> from huggingface_hub.utils import validate_repo_id
+    >>> validate_repo_id(repo_id="valid_repo_id")
+    >>> validate_repo_id(repo_id="other..repo..id")
+    huggingface_hub.utils._validators.HFValidationError: Cannot have -- or .. in repo_id: 'other..repo..id'.
+    ```
+
+    Discussed in https://github.com/huggingface/huggingface_hub/issues/1008.
+    In moon-landing (internal repository):
+    - https://github.com/huggingface/moon-landing/blob/main/server/lib/Names.ts#L27
+    - https://github.com/huggingface/moon-landing/blob/main/server/views/components/NewRepoForm/NewRepoForm.svelte#L138
+    """
+    if not isinstance(repo_id, str):
+        # Typically, a Path is not a repo_id
+        raise HFValidationError(
+            f"Repo id must be a string, not {type(repo_id)}: '{repo_id}'."
+        )
+
+    if repo_id.count("/") > 1:
+        raise HFValidationError(
+            "Repo id must be in the form 'repo_name' or 'namespace/repo_name':"
+            f" '{repo_id}'. Use `repo_type` argument if needed."
+        )
+
+    if not REPO_ID_REGEX.match(repo_id):
+        raise HFValidationError(
+            "Repo id must use alphanumeric chars or '-', '_', '.', '--' and '..' are"
+            " forbidden, '-' and '.' cannot start or end the name, max length is 96:"
+            f" '{repo_id}'."
+        )
+
+    if "--" in repo_id or ".." in repo_id:
+        raise HFValidationError(f"Cannot have -- or .. in repo_id: '{repo_id}'.")
+
+    if repo_id.endswith(".git"):
+        raise HFValidationError(f"Repo_id cannot end by '.git': '{repo_id}'.")


Test patch:
diff --git a/tests/test_file_download.py b/tests/test_file_download.py
index 50b3d4ca..ab7e9c74 100644
--- a/tests/test_file_download.py
+++ b/tests/test_file_download.py
@@ -193,13 +193,6 @@ class CachedDownloadTests(unittest.TestCase):
             repo_type=REPO_TYPE_DATASET,
             revision=DATASET_REVISION_ID_ONE_SPECIFIC_COMMIT,
         )
-        # We can also just get the same url by prefixing "datasets" to repo_id:
-        url2 = hf_hub_url(
-            repo_id=f"datasets/{DATASET_ID}",
-            filename=DATASET_SAMPLE_PY_FILE,
-            revision=DATASET_REVISION_ID_ONE_SPECIFIC_COMMIT,
-        )
-        self.assertEqual(url, url2)
         # now let's download
         filepath = cached_download(url, force_download=True, legacy_cache_layout=True)
         metadata = filename_to_url(filepath, legacy_cache_layout=True)
diff --git a/tests/test_util_validators.py b/tests/test_util_validators.py
new file mode 100644
index 00000000..445ab9a1
--- /dev/null
+++ b/tests/test_util_validators.py
@@ -0,0 +1,61 @@
+import unittest
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+from huggingface_hub.utils import (
+    HFValidationError,
+    validate_hf_hub_args,
+    validate_repo_id,
+)
+
+
+@patch("huggingface_hub.utils._validators.validate_repo_id")
+class TestHfHubValidator(unittest.TestCase):
+    """Test `validate_hf_hub_args` decorator calls all default validators."""
+
+    def test_validate_repo_id_as_arg(self, validate_repo_id_mock: Mock) -> None:
+        """Test `validate_repo_id` is called when `repo_id` is passed as arg."""
+        self.dummy_function(123)
+        validate_repo_id_mock.assert_called_once_with(123)
+
+    def test_validate_repo_id_as_kwarg(self, validate_repo_id_mock: Mock) -> None:
+        """Test `validate_repo_id` is called when `repo_id` is passed as kwarg."""
+        self.dummy_function(repo_id=123)
+        validate_repo_id_mock.assert_called_once_with(123)
+
+    @staticmethod
+    @validate_hf_hub_args
+    def dummy_function(repo_id: str) -> None:
+        pass
+
+
+class TestRepoIdValidator(unittest.TestCase):
+    VALID_VALUES = (
+        "123",
+        "foo",
+        "foo/bar",
+        "Foo-BAR_foo.bar123",
+    )
+    NOT_VALID_VALUES = (
+        Path("foo/bar"),  # Must be a string
+        "a" * 100,  # Too long
+        "datasets/foo/bar",  # Repo_type forbidden in repo_id
+        ".repo_id",  # Cannot start with .
+        "repo_id.",  # Cannot end with .
+        "foo--bar",  # Cannot contain "--"
+        "foo..bar",  # Cannot contain "."
+        "foo.git",  # Cannot end with ".git"
+    )
+
+    def test_valid_repo_ids(self) -> None:
+        """Test `repo_id` validation on valid values."""
+        for repo_id in self.VALID_VALUES:
+            validate_repo_id(repo_id)
+
+    def test_not_valid_repo_ids(self) -> None:
+        """Test `repo_id` validation on not valid values."""
+        for repo_id in self.NOT_VALID_VALUES:
+            with self.assertRaises(
+                HFValidationError, msg=f"'{repo_id}' must not be valid"
+            ):
+                validate_repo_id(repo_id)