Repository: huggingface/huggingface_hub
Pull request #1060 (2022-09-16 14:51:56)

Add regression test for empty modelcard update
Fix https://github.com/huggingface/huggingface_hub/issues/1010.

The issue seems to have been solved by @nateraw changes (https://github.com/huggingface/huggingface_hub/pull/940). I added a regression test just in case in the future.

(cc @cakiki)

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

> :exclamation: Current head 224626d differs from pull request most recent head 4d710b6. Consider uploading reports for the commit 4d710b6 to get more accurate results

```diff
@@             Coverage Diff             @@
##             main    #1060       +/-   ##
===========================================
+ Coverage   31.13%   83.97%   +52.83%     
===========================================
  Files          38       38               
  Lines        4098     4099        +1     
===========================================
+ Hits         1276     3442     +2166     
+ Misses       2822      657     -2165     
```


| [Impacted Files](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) | Coverage Δ | |
|---|---|---|
| [src/huggingface\_hub/\_\_init\_\_.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9fX2luaXRfXy5weQ==) | `75.75% <0.00%> (+3.03%)` | :arrow_up: |
| [src/huggingface\_hub/commands/user.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb21tYW5kcy91c2VyLnB5) | `33.12% <0.00%> (+11.46%)` | :arrow_up: |
| [src/huggingface\_hub/community.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb21tdW5pdHkucHk=) | `91.35% <0.00%> (+17.28%)` | :arrow_up: |
| [src/huggingface\_hub/utils/\_validators.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fdmFsaWRhdG9ycy5weQ==) | `100.00% <0.00%> (+17.85%)` | :arrow_up: |
| [src/huggingface\_hub/utils/\_typing.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fdHlwaW5nLnB5) | `100.00% <0.00%> (+20.00%)` | :arrow_up: |
| [src/huggingface\_hub/utils/\_errors.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fZXJyb3JzLnB5) | `100.00% <0.00%> (+27.41%)` | :arrow_up: |
| [src/huggingface\_hub/utils/\_subprocess.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fc3VicHJvY2Vzcy5weQ==) | `100.00% <0.00%> (+33.33%)` | :arrow_up: |
| [src/huggingface\_hub/utils/\_deprecation.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fZGVwcmVjYXRpb24ucHk=) | `100.00% <0.00%> (+40.35%)` | :arrow_up: |
| [src/huggingface\_hub/commands/\_cli\_utils.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb21tYW5kcy9fY2xpX3V0aWxzLnB5) | `100.00% <0.00%> (+42.85%)` | :arrow_up: |
| [src/huggingface\_hub/commands/scan\_cache.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb21tYW5kcy9zY2FuX2NhY2hlLnB5) | `82.35% <0.00%> (+44.11%)` | :arrow_up: |
| ... and [21 more](https://codecov.io/gh/huggingface/huggingface_hub/pull/1060/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/file_download.mdx b/docs/source/package_reference/file_download.mdx
index 7102613a..5ebaac6e 100644
--- a/docs/source/package_reference/file_download.mdx
+++ b/docs/source/package_reference/file_download.mdx
@@ -1,11 +1,29 @@
 # Downloading files
 
+## Download a single file
+
+### hf_hub_download
+
 [[autodoc]] huggingface_hub.hf_hub_download
 
-[[autodoc]] huggingface_hub.snapshot_download
+### hf_hub_url
 
 [[autodoc]] huggingface_hub.hf_hub_url
 
+## Download a snapshot of the repo
+
+[[autodoc]] huggingface_hub.snapshot_download
+
+## Get metadata about a file
+
+### get_hf_file_metadata
+
+[[autodoc]] huggingface_hub.get_hf_file_metadata
+
+### HfFileMetadata
+
+[[autodoc]] huggingface_hub.HfFileMetadata
+
 ## Caching
 
 The methods displayed above are designed to work with a caching system that prevents
@@ -13,4 +31,4 @@ re-downloading files. The caching system was updated in v0.8.0 to become the cen
 cache-system shared across libraries that depend on the Hub.
 
 Read the [cache-system guide](../how-to-cache) for a detailed presentation of caching at
-at HF.
\ No newline at end of file
+at HF.
diff --git a/src/huggingface_hub/__init__.py b/src/huggingface_hub/__init__.py
index a5c12f2b..78c51bd1 100644
--- a/src/huggingface_hub/__init__.py
+++ b/src/huggingface_hub/__init__.py
@@ -85,7 +85,9 @@ _SUBMOD_ATTRS = {
         "push_to_hub_fastai",
     ],
     "file_download": [
+        "HfFileMetadata",
         "cached_download",
+        "get_hf_file_metadata",
         "hf_hub_download",
         "hf_hub_url",
         "try_to_load_from_cache",
@@ -298,7 +300,9 @@ if TYPE_CHECKING:  # pragma: no cover
     from .fastai_utils import _save_pretrained_fastai  # noqa: F401
     from .fastai_utils import from_pretrained_fastai  # noqa: F401
     from .fastai_utils import push_to_hub_fastai  # noqa: F401
+    from .file_download import HfFileMetadata  # noqa: F401
     from .file_download import cached_download  # noqa: F401
+    from .file_download import get_hf_file_metadata  # noqa: F401
     from .file_download import hf_hub_download  # noqa: F401
     from .file_download import hf_hub_url  # noqa: F401
     from .file_download import try_to_load_from_cache  # noqa: F401
diff --git a/src/huggingface_hub/commands/delete_cache.py b/src/huggingface_hub/commands/delete_cache.py
index 14de621f..37c269dc 100644
--- a/src/huggingface_hub/commands/delete_cache.py
+++ b/src/huggingface_hub/commands/delete_cache.py
@@ -59,9 +59,9 @@ import os
 from argparse import ArgumentParser
 from functools import wraps
 from tempfile import mkstemp
-from typing import Callable, Iterable, List, Optional
+from typing import Any, Callable, Iterable, List, Optional
 
-from ..utils import CachedRepoInfo, HFCacheInfo, scan_cache_dir
+from ..utils import CachedRepoInfo, CachedRevisionInfo, HFCacheInfo, scan_cache_dir
 from . import BaseHuggingfaceCLICommand
 from ._cli_utils import ANSI
 
@@ -256,7 +256,7 @@ def _get_tui_choices_from_scan(
     )
 
     # Display a separator per repo and a Choice for each revisions of the repo
-    for repo in sorted(repos, key=lambda r: (r.repo_type, r.repo_id)):
+    for repo in sorted(repos, key=_repo_sorting_order):
         # Repo as separator
         choices.append(
             Separator(
@@ -264,12 +264,7 @@ def _get_tui_choices_from_scan(
                 f" used {repo.last_accessed_str})"
             )
         )
-        for revision in sorted(
-            # Sort by last modified first
-            repo.revisions,
-            key=lambda r: r.last_modified,
-            reverse=True,
-        ):
+        for revision in sorted(repo.revisions, key=_revision_sorting_order):
             # Revision as choice
             choices.append(
                 Choice(
@@ -300,17 +295,12 @@ def _manual_review_no_tui(
     os.close(fd)
 
     lines = []
-    for repo in sorted(hf_cache_info.repos, key=lambda r: (r.repo_type, r.repo_id)):
+    for repo in sorted(hf_cache_info.repos, key=_repo_sorting_order):
         lines.append(
             f"\n# {repo.repo_type.capitalize()} {repo.repo_id} ({repo.size_on_disk_str},"
             f" used {repo.last_accessed_str})"
         )
-        for revision in sorted(
-            # Sort by last modified first
-            repo.revisions,
-            key=lambda r: r.last_modified,
-            reverse=True,
-        ):
+        for revision in sorted(repo.revisions, key=_revision_sorting_order):
             lines.append(
                 # Deselect by prepending a '#'
                 f"{'' if revision.commit_hash in preselected else '#'}   "
@@ -451,3 +441,13 @@ _MANUAL_REVIEW_NO_TUI_INSTRUCTIONS = f"""
 # REVISIONS
 # ------------
 """.strip()
+
+
+def _repo_sorting_order(repo: CachedRepoInfo) -> Any:
+    # First split by Dataset/Model, then sort by last accessed (oldest first)
+    return (repo.repo_type, repo.last_accessed)
+
+
+def _revision_sorting_order(revision: CachedRevisionInfo) -> Any:
+    # Sort by last modified (oldest first)
+    return revision.last_modified
diff --git a/src/huggingface_hub/file_download.py b/src/huggingface_hub/file_download.py
index f00d3c42..a6a6f70b 100644
--- a/src/huggingface_hub/file_download.py
+++ b/src/huggingface_hub/file_download.py
@@ -8,6 +8,7 @@ import sys
 import tempfile
 import warnings
 from contextlib import contextmanager
+from dataclasses import dataclass
 from functools import partial
 from hashlib import sha256
 from pathlib import Path
@@ -175,6 +176,26 @@ _CACHED_NO_EXIST = object()
 REGEX_COMMIT_HASH = re.compile(r"^[0-9a-f]{40}$")
 
 
+@dataclass(frozen=True)
+class HfFileMetadata:
+    """Data structure containing information about a file versioned on the Hub.
+
+    Returned by [`get_hf_file_metadata`] based on a URL.
+
+    Args:
+        commit_hash (`str`, *optional*):
+            The commit_hash related to the file.
+        etag (`str`, *optional*):
+            Etag of the file on the server.
+        location (`str`):
+            Location where to download the file. Can be a Hub url or not (CDN).
+    """
+
+    commit_hash: Optional[str]
+    etag: Optional[str]
+    location: str
+
+
 # Do not validate `repo_id` in `hf_hub_url` for now as the `repo_id="datasets/.../..."`
 # pattern is used/advertised in Transformers examples.
 # Related: https://github.com/huggingface/huggingface_hub/pull/1029
@@ -513,7 +534,7 @@ def http_get(
     max_retries=0,
 ):
     """
-    Donwload a remote file. Do not gobble up errors, and will return errors tailored to the Hugging Face Hub.
+    Download a remote file. Do not gobble up errors, and will return errors tailored to the Hugging Face Hub.
     """
     headers = copy.deepcopy(headers)
     if resume_size > 0:
@@ -817,7 +838,7 @@ def cached_download(
     return cache_path
 
 
-def _normalize_etag(etag: str) -> str:
+def _normalize_etag(etag: Optional[str]) -> Optional[str]:
     """Normalize ETag HTTP header, so it can be used to create nice filepaths.
 
     The HTTP spec allows two forms of ETag:
@@ -827,11 +848,14 @@ def _normalize_etag(etag: str) -> str:
     The hf.co hub guarantees to only send the second form.
 
     Args:
-        etag (`str`): HTTP header
+        etag (`str`, *optional*): HTTP header
 
     Returns:
-        `str`: string that can be used as a nice directory name.
+        `str` or `None`: string that can be used as a nice directory name.
+        Returns `None` if input is None.
     """
+    if etag is None:
+        return None
     return etag.strip('"')
 
 
@@ -1112,19 +1136,18 @@ def hf_hub_download(
     commit_hash = None
     if not local_files_only:
         try:
-            r = _request_wrapper(
-                method="HEAD",
-                url=url,
-                headers=headers,
-                allow_redirects=False,
-                follow_relative_redirects=True,
-                proxies=proxies,
-                timeout=etag_timeout,
-            )
             try:
-                hf_raise_for_status(r)
-            except EntryNotFoundError:
-                commit_hash = r.headers.get(HUGGINGFACE_HEADER_X_REPO_COMMIT)
+                metadata = get_hf_file_metadata(
+                    url=url,
+                    use_auth_token=use_auth_token,
+                    proxies=proxies,
+                    timeout=etag_timeout,
+                )
+            except EntryNotFoundError as http_error:
+                # Cache the non-existence of the file and raise
+                commit_hash = http_error.response.headers.get(
+                    HUGGINGFACE_HEADER_X_REPO_COMMIT
+                )
                 if commit_hash is not None and not legacy_cache_layout:
                     no_exist_file_path = (
                         Path(storage_folder)
@@ -1138,15 +1161,17 @@ def hf_hub_download(
                         storage_folder, revision, commit_hash
                     )
                 raise
-            commit_hash = r.headers[HUGGINGFACE_HEADER_X_REPO_COMMIT]
+
+            # Commit hash must exist
+            commit_hash = metadata.commit_hash
             if commit_hash is None:
                 raise OSError(
                     "Distant resource does not seem to be on huggingface.co (missing"
                     " commit header)."
                 )
-            etag = r.headers.get(HUGGINGFACE_HEADER_X_LINKED_ETAG) or r.headers.get(
-                "ETag"
-            )
+
+            # Etag must exist
+            etag = metadata.etag
             # We favor a custom header indicating the etag of the linked resource, and
             # we fallback to the regular etag header.
             # If we don't have any of those, raise an error.
@@ -1155,13 +1180,13 @@ def hf_hub_download(
                     "Distant resource does not have an ETag, we won't be able to"
                     " reliably ensure reproducibility."
                 )
-            etag = _normalize_etag(etag)
+
             # In case of a redirect, save an extra redirect on the request.get call,
             # and ensure we download the exact atomic version even if it changed
             # between the HEAD and the GET (unlikely, but hey).
             # Useful for lfs blobs that are stored on a CDN.
-            if 300 <= r.status_code <= 399:
-                url_to_download = r.headers["Location"]
+            if metadata.location != url:
+                url_to_download = metadata.location
                 if (
                     "lfs.huggingface.co" in url_to_download
                     or "lfs-staging.huggingface.co" in url_to_download
@@ -1377,3 +1402,71 @@ def try_to_load_from_cache(
 
     cached_file = os.path.join(repo_cache, "snapshots", revision, filename)
     return cached_file if os.path.isfile(cached_file) else None
+
+
+def get_hf_file_metadata(
+    url: str,
+    use_auth_token: Union[bool, str, None] = None,
+    proxies: Optional[Dict] = None,
+    timeout: Optional[float] = 10,
+) -> HfFileMetadata:
+    """Fetch metadata of a file versioned on the Hub for a given url.
+
+    Args:
+        url (`str`):
+            File url, for example returned by [`hf_hub_url`].
+        use_auth_token (`str` or `bool`, *optional*):
+            A token to be used for the download.
+                - If `True`, the token is read from the HuggingFace config
+                  folder.
+                - If `False` or `None`, no token is provided.
+                - If a string, it's used as the authentication token.
+        proxies (`dict`, *optional*):
+            Dictionary mapping protocol to the URL of the proxy passed to
+            `requests.request`.
+        etag_timeout (`float`, *optional*, defaults to 10):
+            How many seconds to wait for the server to send metadata before giving up.
+
+    Returns:
+        A [`HfFileMetadata`] object containing metadata such as location, etag and
+        commit_hash.
+    """
+    # TODO: helper to get headers from `use_auth_token` (copy-pasted several times)
+    headers = {}
+    if isinstance(use_auth_token, str):
+        headers["authorization"] = f"Bearer {use_auth_token}"
+    elif use_auth_token:
+        token = HfFolder.get_token()
+        if token is None:
+            raise EnvironmentError(
+                "You specified use_auth_token=True, but a huggingface token was not"
+                " found."
+            )
+        headers["authorization"] = f"Bearer {token}"
+
+    # Retrieve metadata
+    r = _request_wrapper(
+        method="HEAD",
+        url=url,
+        headers=headers,
+        allow_redirects=False,
+        follow_relative_redirects=True,
+        proxies=proxies,
+        timeout=timeout,
+    )
+    hf_raise_for_status(r)
+
+    # Return
+    return HfFileMetadata(
+        commit_hash=r.headers.get(HUGGINGFACE_HEADER_X_REPO_COMMIT),
+        etag=_normalize_etag(
+            # We favor a custom header indicating the etag of the linked resource, and
+            # we fallback to the regular etag header.
+            r.headers.get("ETag")
+            or r.headers.get(HUGGINGFACE_HEADER_X_LINKED_ETAG)
+        ),
+        # Either from response headers (if redirected) or defaults to request url
+        # Do not use directly `url`, as `_request_wrapper` might have followed relative
+        # redirects.
+        location=r.headers.get("Location") or r.request.url,
+    )


Test patch:
diff --git a/tests/test_command_delete_cache.py b/tests/test_command_delete_cache.py
index a034ce86..00c5844a 100644
--- a/tests/test_command_delete_cache.py
+++ b/tests/test_command_delete_cache.py
@@ -67,17 +67,17 @@ class TestDeleteCacheHelpers(unittest.TestCase):
             choices[3]._line, "\nModel dummy_model (1.4K, used 2 years ago)"
         )
 
-        # Newest revision of `dummy_model`
+        # Oldest revision of `dummy_model`
         self.assertIsInstance(choices[4], Choice)
-        self.assertEqual(choices[4].value, "recent_hash_id")
-        self.assertEqual(choices[4].name, "recent_h: main # modified 2 years ago")
-        self.assertFalse(choices[4].enabled)
+        self.assertEqual(choices[4].value, "older_hash_id")
+        self.assertEqual(choices[4].name, "older_ha: (detached) # modified 3 years ago")
+        self.assertTrue(choices[4].enabled)  # preselected
 
-        # Oldest revision of `dummy_model`
+        # Newest revision of `dummy_model`
         self.assertIsInstance(choices[5], Choice)
-        self.assertEqual(choices[5].value, "older_hash_id")
-        self.assertEqual(choices[5].name, "older_ha: (detached) # modified 3 years ago")
-        self.assertTrue(choices[5].enabled)  # preselected
+        self.assertEqual(choices[5].value, "recent_hash_id")
+        self.assertEqual(choices[5].name, "recent_h: main # modified 2 years ago")
+        self.assertFalse(choices[5].enabled)
 
         # Model `gpt2` separator
         self.assertIsInstance(choices[6], Separator)
@@ -396,6 +396,7 @@ def _get_cache_mock() -> Mock:
     model_1.repo_type = "model"
     model_1.repo_id = "gpt2"
     model_1.size_on_disk_str = "3.6G"
+    model_1.last_accessed = 1660000000
     model_1.last_accessed_str = "2 hours ago"
 
     model_1_revision_1 = Mock()
@@ -411,6 +412,7 @@ def _get_cache_mock() -> Mock:
     model_2.repo_type = "model"
     model_2.repo_id = "dummy_model"
     model_2.size_on_disk_str = "1.4K"
+    model_2.last_accessed = 1550000000
     model_2.last_accessed_str = "2 years ago"
 
     model_2_revision_1 = Mock()
@@ -432,6 +434,7 @@ def _get_cache_mock() -> Mock:
     dataset_1.repo_type = "dataset"
     dataset_1.repo_id = "dummy_dataset"
     dataset_1.size_on_disk_str = "8M"
+    dataset_1.last_accessed = 1660000000
     dataset_1.last_accessed_str = "2 weeks ago"
 
     dataset_1_revision_1 = Mock()
diff --git a/tests/test_file_download.py b/tests/test_file_download.py
index 13ae0e9d..7774dd27 100644
--- a/tests/test_file_download.py
+++ b/tests/test_file_download.py
@@ -28,6 +28,7 @@ from huggingface_hub.file_download import (
     _CACHED_NO_EXIST,
     cached_download,
     filename_to_url,
+    get_hf_file_metadata,
     hf_hub_download,
     hf_hub_url,
     try_to_load_from_cache,
@@ -45,7 +46,8 @@ from .testing_utils import (
     DUMMY_MODEL_ID_PINNED_SHA256,
     DUMMY_MODEL_ID_REVISION_INVALID,
     DUMMY_MODEL_ID_REVISION_ONE_SPECIFIC_COMMIT,
-    DUMMY_RENAMED_MODEL_ID,
+    DUMMY_RENAMED_NEW_MODEL_ID,
+    DUMMY_RENAMED_OLD_MODEL_ID,
     SAMPLE_DATASET_IDENTIFIER,
     OfflineSimulationMode,
     offline,
@@ -225,7 +227,7 @@ class CachedDownloadTests(unittest.TestCase):
         """
         with TemporaryDirectory() as tmpdir:
             filepath = hf_hub_download(
-                DUMMY_RENAMED_MODEL_ID, "config.json", cache_dir=tmpdir
+                DUMMY_RENAMED_OLD_MODEL_ID, "config.json", cache_dir=tmpdir
             )
             self.assertTrue(os.path.exists(filepath))
 
@@ -239,7 +241,7 @@ class CachedDownloadTests(unittest.TestCase):
             with TemporaryDirectory() as tmpdir:
                 filepath = cached_download(
                     hf_hub_url(
-                        DUMMY_RENAMED_MODEL_ID,
+                        DUMMY_RENAMED_OLD_MODEL_ID,
                         filename="config.json",
                     ),
                     cache_dir=tmpdir,
@@ -337,3 +339,34 @@ class CachedDownloadTests(unittest.TestCase):
 
         # If file non-existence is not cached, returns None
         self.assertIsNone(try_to_load_from_cache(DUMMY_MODEL_ID, filename="dummy2"))
+
+    def test_get_hf_file_metadata_basic(self) -> None:
+        """Test getting metadata from a file on the Hub."""
+        url = hf_hub_url(
+            DUMMY_MODEL_ID,
+            filename=CONFIG_NAME,
+            revision=DUMMY_MODEL_ID_REVISION_ONE_SPECIFIC_COMMIT,
+        )
+        metadata = get_hf_file_metadata(url)
+
+        # Metadata
+        self.assertEqual(
+            metadata.commit_hash, DUMMY_MODEL_ID_REVISION_ONE_SPECIFIC_COMMIT
+        )
+        self.assertIsNotNone(metadata.etag)  # example: "85c2fc2dcdd86563aaa85ef4911..."
+        self.assertEqual(metadata.location, url)  # no redirect
+
+    def test_get_hf_file_metadata_from_a_renamed_repo(self) -> None:
+        """Test getting metadata from a file in a renamed repo on the Hub."""
+        url = hf_hub_url(
+            DUMMY_RENAMED_OLD_MODEL_ID,
+            filename=CONFIG_NAME,
+            subfolder="",  # Subfolder should be processed as `None`
+        )
+        metadata = get_hf_file_metadata(url)
+
+        # Got redirected to renamed repo
+        self.assertEqual(
+            metadata.location,
+            url.replace(DUMMY_RENAMED_OLD_MODEL_ID, DUMMY_RENAMED_NEW_MODEL_ID),
+        )
diff --git a/tests/test_repocard.py b/tests/test_repocard.py
index 06587233..4d53c83e 100644
--- a/tests/test_repocard.py
+++ b/tests/test_repocard.py
@@ -130,6 +130,11 @@ model-index:
 ---
 """
 
+DUMMY_MODELCARD_NO_TEXT_CONTENT = """---
+license: cc-by-sa-4.0
+---
+"""
+
 logger = logging.get_logger(__name__)
 
 REPOCARD_DIR = os.path.join(
@@ -394,6 +399,23 @@ class RepocardMetadataUpdateTest(unittest.TestCase):
         updated_metadata = metadata_load(self.repo_path / self.REPO_NAME / "README.md")
         self.assertDictEqual(updated_metadata, expected_metadata)
 
+    def test_update_metadata_on_empty_text_content(self) -> None:
+        """Test `update_metadata` on a model card that has metadata but no text content
+
+        Regression test for https://github.com/huggingface/huggingface_hub/issues/1010
+        """
+        # Create modelcard with metadata but empty text content
+        with self.repo.commit("Add README to main branch"):
+            with open("README.md", "w") as f:
+                f.write(DUMMY_MODELCARD_NO_TEXT_CONTENT)
+        metadata_update(f"{USER}/{self.REPO_NAME}", {"tag": "test"}, token=self._token)
+
+        # Check update went fine
+        self.repo.git_pull()
+        updated_metadata = metadata_load(self.repo_path / self.REPO_NAME / "README.md")
+        expected_metadata = {"license": "cc-by-sa-4.0", "tag": "test"}
+        self.assertDictEqual(updated_metadata, expected_metadata)
+
 
 class TestCaseWithCapLog(unittest.TestCase):
     _api = HfApi(endpoint=ENDPOINT_STAGING)
diff --git a/tests/testing_utils.py b/tests/testing_utils.py
index 09ed1741..a1fb9ea1 100644
--- a/tests/testing_utils.py
+++ b/tests/testing_utils.py
@@ -41,9 +41,8 @@ DUMMY_MODEL_ID_PINNED_SHA256 = (
 # Sha-256 of pytorch_model.bin on the top of `main`, for checking purposes
 
 # "hf-internal-testing/dummy-will-be-renamed" has been renamed to "hf-internal-testing/dummy-renamed"
-DUMMY_RENAMED_MODEL_ID = (  # Regression test #941
-    "hf-internal-testing/dummy-will-be-renamed"
-)
+DUMMY_RENAMED_OLD_MODEL_ID = "hf-internal-testing/dummy-will-be-renamed"
+DUMMY_RENAMED_NEW_MODEL_ID = "hf-internal-testing/dummy-renamed"
 
 SAMPLE_DATASET_IDENTIFIER = "lhoestq/custom_squad"
 # Example dataset ids