Repository: huggingface/huggingface_hub
Pull request #1067 (2022-09-20 15:31:50)

Degraded but fully working cache-system when symlinks are not supported
Related to https://github.com/huggingface/huggingface_hub/issues/1062.

The proposed approach is to use the new cache system on every platform but have a "degraded" version when symlinks are not supported. The degraded version do not use symlinks but rather copypaste/move the blob files instead. Drawback is that some files might get duplicated in the cache if several revisions of the same repo are downloaded. This drawback can be mitigated by deleting older revisions (see `huggingface-cli delete-cache` command https://github.com/huggingface/huggingface_hub/issues/1025). The advantage of this solution is that it should work seamlessly from a user perspective but also for developers (e.g. when downloading a repo, the repo structure will still be the same on disk)

A warning will be displayed to encourage the user to enable developer mode/run as admin for a better cache system. Warning message can be disabled via a environment variable.

**Todo:**
- [x] detect platform that doesn't support symlinks
- [x] move/copypaste file when symlinks are not supported
- [x] test downloading a file when symlinks not supported
- [x] test scan cache + delete cache when symlinks are not supported (or even half-supported)
- [x] documentation

codecov[bot]: # [Codecov](https://codecov.io/gh/huggingface/huggingface_hub/pull/1067?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) Report
Base: **84.19**% // Head: **84.24**% // Increases project coverage by **`+0.04%`** :tada:
> Coverage data is based on head [(`560d41b`)](https://codecov.io/gh/huggingface/huggingface_hub/pull/1067?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) compared to base [(`15d0f5d`)](https://codecov.io/gh/huggingface/huggingface_hub/commit/15d0f5d25057252d39b09c7c25ca7598bcb97f0b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).
> Patch coverage: 70.83% of modified lines in pull request are covered.

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


```diff
@@            Coverage Diff             @@
##             main    #1067      +/-   ##
==========================================
+ Coverage   84.19%   84.24%   +0.04%     
==========================================
  Files          38       38              
  Lines        4120     4131      +11     
==========================================
+ Hits         3469     3480      +11     
  Misses        651      651              
```


| [Impacted Files](https://codecov.io/gh/huggingface/huggingface_hub/pull/1067?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) | Coverage Δ | |
|---|---|---|
| [src/huggingface\_hub/utils/\_cache\_manager.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1067/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fY2FjaGVfbWFuYWdlci5weQ==) | `96.55% <ø> (+0.77%)` | :arrow_up: |
| [src/huggingface\_hub/file\_download.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1067/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9maWxlX2Rvd25sb2FkLnB5) | `86.84% <70.83%> (+0.02%)` | :arrow_up: |

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)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/huggingface/huggingface_hub/pull/1067?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).

Wauplin: @LysandreJik that would be perfect thank you :hugs: 
LysandreJik: I just checked on Windows by first disabling developer mode. Using v0.9.1 or `main`, I get the following error:

```python
>>> from huggingface_hub import snapshot_download
>>> snapshot_download('distilbert-base-uncased')
```
```
Downloading: 100%|█████████████████████████████████████████████████████████████████████| 391/391 [00:00<00:00, 392kB/s]
Fetching 11 files:   0%|                                                                        | 0/11 [00:00<?, ?it/s]
Traceback (most recent call last):
  File "C:\Users\Lysandre\PycharmProjects\huggingface_hub\src\huggingface_hub\file_download.py", line 879, in _create_relative_symlink
    os.symlink(relative_src, dst)
OSError: [WinError 1314] A required privilege is not held by the client: '..\\..\\blobs\\ae8c63daedbd4206d7d40126955d4e6ab1c80f8f' -> 'C:\\Users\\Lysandre\\.cache\\huggingface\\hub\\models--distilbert-base-uncased\\snapshots\\043235d6088ecd3dd5fb5ca3592b6913fd516027\\.gitattributes'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\Users\Lysandre\PycharmProjects\huggingface_hub\src\huggingface_hub\utils\_deprecation.py", line 93, in inner_f
    return f(*args, **kwargs)
  File "C:\Users\Lysandre\PycharmProjects\huggingface_hub\src\huggingface_hub\utils\_validators.py", line 90, in _inner_fn
    return fn(*args, **kwargs)
  File "C:\Users\Lysandre\PycharmProjects\huggingface_hub\src\huggingface_hub\_snapshot_download.py", line 197, in snapshot_download
    _ = hf_hub_download(
  File "C:\Users\Lysandre\PycharmProjects\huggingface_hub\src\huggingface_hub\file_download.py", line 1327, in hf_hub_download
    _create_relative_symlink(blob_path, pointer_path)
  File "C:\Users\Lysandre\PycharmProjects\huggingface_hub\src\huggingface_hub\file_download.py", line 883, in _create_relative_symlink
    raise OSError(
OSError: Windows requires Developer Mode to be activated, or to run Python as an administrator, in order to create symlinks.
In order to activate Developer
```

Checking out this current branch and running the code:

```python
>>> from huggingface_hub import snapshot_download
>>> snapshot_download('distilbert-base-cased')
```
```
Fetching 8 files:   0%|                                                                          | 0/8 [00:00<?, ?it/s]C:\Users\Lysandre\PycharmProjects\huggingface_hub\src\huggingface_hub\file_download.py:214: UserWarning: `huggingface_hub` cache-system uses symlinks by default to efficiently store duplicated files but your machine doesn't support them. Caching files will still work but in a degraded version that might require more space on your disk. This warning can be disabled by setting the `DISABLE_SYMLINKS_WARNING` environment variable. For more details, see https://huggingface.co/docs/huggingface_hub/how-to-cache#limitations.
To support symlinks on Windows, you either need to activate Developer Mode or to run Python as an administrator. In order to see activate developer mode, see this article: https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development
  warnings.warn(message)
Downloading: 100%|████████████████████████████████████████████████████████████████| 8.79k/8.79k [00:00<00:00, 8.86MB/s]
Downloading: 100%|█████████████████████████████████████████████████████████████████████| 411/411 [00:00<00:00, 416kB/s]
Downloading: 100%|██████████████████████████████████████████████████████████████████| 263M/263M [00:32<00:00, 8.18MB/s]
Downloading: 100%|██████████████████████████████████████████████████████████████████| 354M/354M [00:41<00:00, 8.45MB/s]
Downloading: 100%|██████████████████████████████████████████████████████████████████| 436k/436k [00:00<00:00, 1.35MB/s]
Downloading: 100%|██████████████████████████████████████████████████████████████████| 29.0/29.0 [00:00<00:00, 29.0kB/s]
Downloading: 100%|██████████████████████████████████████████████████████████████████| 213k/213k [00:00<00:00, 2.89MB/s]
Fetching 8 files: 100%|██████████████████████████████████████████████████████████████████| 8/8 [01:16<00:00,  9.56s/it]
'C:\\Users\\Lysandre\\.cache\\huggingface\\hub\\models--distilbert-base-cased\\snapshots\\8d708decd7afb7bec0af233e5338fe1fca3db705'
```

It seems it works! Any other situation you'd like me to test?
Wauplin: Thanks a lot @LysandreJik, that's reassuring ! Just to be sure, could you also try `huggingface-cli scan-cache` and `huggingface-cli delete-cache` ?
Otherwise I think it's safe to assume we can merge :)

Patch:
diff --git a/docs/source/how-to-cache.mdx b/docs/source/how-to-cache.mdx
index df5c1ce0..9b04b94e 100644
--- a/docs/source/how-to-cache.mdx
+++ b/docs/source/how-to-cache.mdx
@@ -109,6 +109,25 @@ In practice, your cache should look like the following tree:
                 └── [  76]  pytorch_model.bin -> ../../blobs/403450e234d65943a7dcf7e05a771ce3c92faa84dd07db4ac20f592037a1e4bd
 ```
 
+### Limitations
+
+In order to have an efficient cache-system, `huggingface-hub` uses symlinks. However,
+symlinks are not supported on all machines. This is a known limitation especially on
+Windows. When this is the case, `huggingface_hub` do not use the `blobs/` directory but
+directly stores the files in the `snapshots/` directory instead. This workaround allows
+users to download and cache files from the Hub exactly the same way. Tools to inspect
+and delete the cache (see below) are also supported. However, the cache-system is less
+efficient as a single file might be downloaded several times if multiple revisions of
+the same repo is downloaded.
+
+If you want to benefit from the symlink-based cache-system on a Windows machine, you
+either need to [activate Developer Mode](https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development)
+or to run Python as an administrator.
+
+When symlinks are not supported, a warning message is displayed to the user to alert
+them they are using a degraded version of the cache-system. This warning can be disabled
+by setting the `DISABLE_SYMLINKS_WARNING` environment variable to true.
+
 ## Scan your cache
 
 At the moment, cached files are never deleted from your local directory: when you download
diff --git a/src/huggingface_hub/commands/user.py b/src/huggingface_hub/commands/user.py
index e06b1367..1fe9ea94 100644
--- a/src/huggingface_hub/commands/user.py
+++ b/src/huggingface_hub/commands/user.py
@@ -307,7 +307,7 @@ def notebook_login():
         # Erase token and clear value to make sure it's not saved in the notebook.
         token_widget.value = ""
         clear_output()
-        _login(token=token)
+        _login(hf_api=HfApi(), token=token)
 
     token_finish_button.on_click(login_token_event)
 
diff --git a/src/huggingface_hub/file_download.py b/src/huggingface_hub/file_download.py
index 374c3c70..57b57795 100644
--- a/src/huggingface_hub/file_download.py
+++ b/src/huggingface_hub/file_download.py
@@ -4,6 +4,7 @@ import io
 import json
 import os
 import re
+import shutil
 import sys
 import tempfile
 import warnings
@@ -171,6 +172,50 @@ def get_jinja_version():
     return _jinja_version
 
 
+_are_symlinks_supported: Optional[bool] = None
+
+
+def are_symlinks_supported() -> bool:
+    # Check symlink compatibility only once at first time use
+    global _are_symlinks_supported
+
+    if _are_symlinks_supported is None:
+        _are_symlinks_supported = True
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            src_path = Path(tmpdir) / "dummy_file_src"
+            src_path.touch()
+            dst_path = Path(tmpdir) / "dummy_file_dst"
+            try:
+                os.symlink(src_path, dst_path)
+            except OSError:
+                # Likely running on Windows
+                _are_symlinks_supported = False
+
+                if not os.environ.get("DISABLE_SYMLINKS_WARNING"):
+                    message = (
+                        "`huggingface_hub` cache-system uses symlinks by default to"
+                        " efficiently store duplicated files but your machine doesn't"
+                        " support them. Caching files will still work but in a degraded"
+                        " version that might require more space on your disk. This"
+                        " warning can be disabled by setting the"
+                        " `DISABLE_SYMLINKS_WARNING` environment variable. For more"
+                        " details, see"
+                        " https://huggingface.co/docs/huggingface_hub/how-to-cache#limitations."
+                    )
+                    if os.name == "nt":
+                        message += (
+                            "\nTo support symlinks on Windows, you either need to"
+                            " activate Developer Mode or to run Python as an"
+                            " administrator. In order to see activate developer mode,"
+                            " see this article:"
+                            " https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development"
+                        )
+                    warnings.warn(message)
+
+    return _are_symlinks_supported
+
+
 # Return value when trying to load a file from cache but the file does not exist in the distant repo.
 _CACHED_NO_EXIST = object()
 REGEX_COMMIT_HASH = re.compile(r"^[0-9a-f]{40}$")
@@ -848,7 +893,7 @@ def _normalize_etag(etag: Optional[str]) -> Optional[str]:
     return etag.strip('"')
 
 
-def _create_relative_symlink(src: str, dst: str) -> None:
+def _create_relative_symlink(src: str, dst: str, new_blob: bool = False) -> None:
     """Create a symbolic link named dst pointing to src as a relative path to dst.
 
     The relative part is mostly because it seems more elegant to the author.
@@ -858,25 +903,29 @@ def _create_relative_symlink(src: str, dst: str) -> None:
             ├── [ 128]  2439f60ef33a0d46d85da5001d52aeda5b00ce9f
             │   ├── [  52]  README.md -> ../../blobs/d7edf6bd2a681fb0175f7735299831ee1b22b812
             │   └── [  76]  pytorch_model.bin -> ../../blobs/403450e234d65943a7dcf7e05a771ce3c92faa84dd07db4ac20f592037a1e4bd
+
+    If symlinks cannot be created on this platform (most likely to be Windows), the
+    workaround is to avoid symlinks by having the actual file in `dst`. If it is a new
+    file (`new_blob=True`), we move it to `dst`. If it is not a new file
+    (`new_blob=False`), we don't know if the blob file is already referenced elsewhere.
+    To avoid breaking existing cache, the file is duplicated on the disk.
+
+    In case symlinks are not supported, a warning message is displayed to the user once
+    when loading `huggingface_hub`. The warning message can be disable with the
+    `DISABLE_SYMLINKS_WARNING` environment variable.
     """
     relative_src = os.path.relpath(src, start=os.path.dirname(dst))
     try:
         os.remove(dst)
     except OSError:
         pass
-    try:
+
+    if are_symlinks_supported():
         os.symlink(relative_src, dst)
-    except OSError:
-        # Likely running on Windows
-        if os.name == "nt":
-            raise OSError(
-                "Windows requires Developer Mode to be activated, or to run Python as "
-                "an administrator, in order to create symlinks.\nIn order to "
-                "activate Developer Mode, see this article: "
-                "https://docs.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development"
-            )
-        else:
-            raise
+    elif new_blob:
+        os.replace(src, dst)
+    else:
+        shutil.copyfile(src, dst)
 
 
 def _cache_commit_hash_for_specific_revision(
@@ -1246,7 +1295,7 @@ def hf_hub_download(
     if os.path.exists(blob_path) and not force_download:
         # we have the blob already, but not the pointer
         logger.info("creating pointer to %s from %s", blob_path, pointer_path)
-        _create_relative_symlink(blob_path, pointer_path)
+        _create_relative_symlink(blob_path, pointer_path, new_blob=False)
         return pointer_path
 
     # Prevent parallel downloads of the same file with a lock.
@@ -1302,7 +1351,7 @@ def hf_hub_download(
         os.replace(temp_file.name, blob_path)
 
         logger.info("creating pointer to %s from %s", blob_path, pointer_path)
-        _create_relative_symlink(blob_path, pointer_path)
+        _create_relative_symlink(blob_path, pointer_path, new_blob=True)
 
     try:
         os.remove(lock_path)
diff --git a/src/huggingface_hub/utils/_cache_manager.py b/src/huggingface_hub/utils/_cache_manager.py
index 5677b8f8..43ca2d7a 100644
--- a/src/huggingface_hub/utils/_cache_manager.py
+++ b/src/huggingface_hub/utils/_cache_manager.py
@@ -631,7 +631,6 @@ def _scan_cached_repo(repo_path: Path) -> CachedRepoInfo:
 
     snapshots_path = repo_path / "snapshots"
     refs_path = repo_path / "refs"
-    blobs_path = repo_path / "blobs"
 
     if not snapshots_path.exists() or not snapshots_path.is_dir():
         raise CorruptedCacheException(
@@ -679,22 +678,12 @@ def _scan_cached_repo(repo_path: Path) -> CachedRepoInfo:
             if file_path.is_dir():
                 continue
 
-            if not file_path.is_symlink():
-                raise CorruptedCacheException(
-                    f"Revision folder corrupted. Found a non-symlink file: {file_path}"
-                )
-
             blob_path = Path(file_path).resolve()
             if not blob_path.exists():
                 raise CorruptedCacheException(
                     f"Blob missing (broken symlink): {blob_path}"
                 )
 
-            if blobs_path not in blob_path.parents:
-                raise CorruptedCacheException(
-                    f"Blob symlink points outside of blob directory: {blob_path}"
-                )
-
             if blob_path not in blob_stats:
                 blob_stats[blob_path] = blob_path.stat()
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 3c871da7..dc4316e8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,10 +1,32 @@
+from pathlib import Path
+from tempfile import TemporaryDirectory
 from typing import Generator
 
 import pytest
 
+from _pytest.fixtures import SubRequest
 from huggingface_hub import HfFolder
 
 
+@pytest.fixture
+def fx_cache_dir(request: SubRequest) -> Generator[None, None, None]:
+    """Add a `cache_dir` attribute pointing to a temporary directory in tests.
+
+    Example:
+    ```py
+    @pytest.mark.usefixtures("fx_cache_dir")
+    class TestWithCache(unittest.TestCase):
+        cache_dir: Path
+
+        def test_cache_dir(self) -> None:
+            self.assertTrue(self.cache_dir.is_dir())
+    ```
+    """
+    with TemporaryDirectory() as cache_dir:
+        request.cls.cache_dir = Path(cache_dir).resolve()
+        yield
+
+
 @pytest.fixture(autouse=True, scope="session")
 def clean_hf_folder_token_for_tests() -> Generator:
     """Clean token stored on machine before all tests and reset it back at the end.
diff --git a/tests/test_cache_no_symlinks.py b/tests/test_cache_no_symlinks.py
new file mode 100644
index 00000000..9c88954e
--- /dev/null
+++ b/tests/test_cache_no_symlinks.py
@@ -0,0 +1,206 @@
+import unittest
+import warnings
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import pytest
+
+from huggingface_hub import hf_hub_download, scan_cache_dir
+from huggingface_hub.constants import CONFIG_NAME
+from huggingface_hub.file_download import are_symlinks_supported
+
+from .testing_constants import TOKEN
+from .testing_utils import DUMMY_MODEL_ID, with_production_testing
+
+
+@with_production_testing
+@pytest.mark.usefixtures("fx_cache_dir")
+class TestCacheLayoutIfSymlinksNotSupported(unittest.TestCase):
+    cache_dir: Path
+
+    @patch("huggingface_hub.file_download._are_symlinks_supported", None)
+    def test_are_symlinks_supported_normal(self) -> None:
+        self.assertTrue(are_symlinks_supported())
+
+    @patch("huggingface_hub.file_download.os.symlink")  # Symlinks not supported
+    @patch("huggingface_hub.file_download._are_symlinks_supported", None)  # first use
+    def test_are_symlinks_supported_windows(self, mock_symlink: Mock) -> None:
+        mock_symlink.side_effect = OSError()
+
+        # First time: warning is raised
+        with self.assertWarns(UserWarning):
+            self.assertFalse(are_symlinks_supported())
+
+        # Afterward: value is cached (no warning raised)
+        with warnings.catch_warnings():
+            # Taken from https://stackoverflow.com/a/45671804
+            warnings.simplefilter("error")
+            self.assertFalse(are_symlinks_supported())
+
+    @patch("huggingface_hub.file_download.are_symlinks_supported")
+    def test_download_no_symlink_new_file(
+        self, mock_are_symlinks_supported: Mock
+    ) -> None:
+        mock_are_symlinks_supported.return_value = False
+        filepath = Path(
+            hf_hub_download(
+                DUMMY_MODEL_ID,
+                filename=CONFIG_NAME,
+                cache_dir=self.cache_dir,
+                local_files_only=False,
+                use_auth_token=TOKEN,
+            )
+        )
+        # Not a symlink !
+        self.assertFalse(filepath.is_symlink())
+        self.assertTrue(filepath.is_file())
+
+        # Blobs directory is empty
+        self.assertEqual(len(list((Path(filepath).parents[2] / "blobs").glob("*"))), 0)
+
+    @patch("huggingface_hub.file_download.are_symlinks_supported")
+    def test_download_no_symlink_existing_file(
+        self, mock_are_symlinks_supported: Mock
+    ) -> None:
+        mock_are_symlinks_supported.return_value = True
+        filepath = Path(
+            hf_hub_download(
+                DUMMY_MODEL_ID,
+                filename=CONFIG_NAME,
+                cache_dir=self.cache_dir,
+                local_files_only=False,
+                use_auth_token=TOKEN,
+            )
+        )
+        self.assertTrue(filepath.is_symlink())
+        blob_path = filepath.resolve()
+        self.assertTrue(blob_path.is_file())
+
+        # Delete file in snapshot
+        filepath.unlink()
+
+        # Re-download but symlinks are not supported anymore (example: not an admin)
+        mock_are_symlinks_supported.return_value = False
+        new_filepath = Path(
+            hf_hub_download(
+                DUMMY_MODEL_ID,
+                filename=CONFIG_NAME,
+                cache_dir=self.cache_dir,
+                local_files_only=False,
+                use_auth_token=TOKEN,
+            )
+        )
+        # File exist but is not a symlink
+        self.assertFalse(new_filepath.is_symlink())
+        self.assertTrue(new_filepath.is_file())
+
+        # Blob file still exists as well (has not been deleted)
+        # => duplicate file on disk
+        self.assertTrue(blob_path.is_file())
+
+    @patch("huggingface_hub.file_download.are_symlinks_supported")
+    def test_scan_and_delete_cache_no_symlinks(
+        self, mock_are_symlinks_supported: Mock
+    ) -> None:
+        """Test scan_cache_dir works as well when cache-system doesn't use symlinks."""
+        OLDER_REVISION = "44c70f043cfe8162efc274ff531575e224a0e6f0"
+
+        # Symlinks not supported
+        mock_are_symlinks_supported.return_value = False
+
+        # Download config.json from main
+        hf_hub_download(
+            DUMMY_MODEL_ID,
+            filename=CONFIG_NAME,
+            cache_dir=self.cache_dir,
+            use_auth_token=TOKEN,
+        )
+
+        # Download README.md from main
+        hf_hub_download(
+            DUMMY_MODEL_ID,
+            filename="README.md",
+            cache_dir=self.cache_dir,
+            use_auth_token=TOKEN,
+        )
+
+        # Download config.json from older revision
+        hf_hub_download(
+            DUMMY_MODEL_ID,
+            filename=CONFIG_NAME,
+            cache_dir=self.cache_dir,
+            use_auth_token=TOKEN,
+            revision=OLDER_REVISION,
+        )
+
+        # Now symlinks work: user has rerun the script as admin
+        mock_are_symlinks_supported.return_value = True
+
+        # Download merges.txt from older revision with symlinks
+        hf_hub_download(
+            DUMMY_MODEL_ID,
+            filename="merges.txt",
+            cache_dir=self.cache_dir,
+            use_auth_token=TOKEN,
+            revision=OLDER_REVISION,
+        )
+
+        # Scan cache directory
+        report = scan_cache_dir(self.cache_dir)
+
+        # 1 repo found, no warnings
+        self.assertEqual(len(report.repos), 1)
+        self.assertEqual(len(report.warnings), 0)
+        repo = list(report.repos)[0]
+
+        # 2 revisions found
+        self.assertEqual(len(repo.revisions), 2)
+        self.assertEqual(repo.nb_files, 4)
+        self.assertEqual(len(repo.refs), 1)  # only `main`
+        main_revision = repo.refs["main"]
+        main_ref = main_revision.commit_hash
+        older_revision = [rev for rev in repo.revisions if rev is not main_revision][0]
+
+        # 2 files in `main` revisions, both are not symlinks
+        self.assertEqual(main_revision.nb_files, 2)
+        for file in main_revision.files:
+            # No symlinks means the files are in the snapshot dir itself
+            self.assertTrue(main_revision.snapshot_path in file.blob_path.parents)
+
+        # 2 files in older revision, only 1 as symlink
+        for file in older_revision.files:
+            if file.file_name == CONFIG_NAME:
+                # In snapshot dir
+                self.assertTrue(older_revision.snapshot_path in file.blob_path.parents)
+            else:
+                # In blob dir
+                self.assertFalse(older_revision.snapshot_path in file.blob_path.parents)
+                self.assertTrue("blobs" in str(file.blob_path))
+
+        # Since files are not shared (README.md is duplicated in cache), the total size
+        # of the repo is the sum of each revision size. If symlinks were used, the total
+        # size of the repo would be lower.
+        self.assertEqual(
+            repo.size_on_disk, main_revision.size_on_disk + older_revision.size_on_disk
+        )
+
+        # Test delete repo strategy
+        strategy_delete_repo = report.delete_revisions(main_ref, OLDER_REVISION)
+        self.assertEqual(strategy_delete_repo.expected_freed_size, repo.size_on_disk)
+        self.assertEqual(len(strategy_delete_repo.blobs), 0)
+        self.assertEqual(len(strategy_delete_repo.snapshots), 0)
+        self.assertEqual(len(strategy_delete_repo.refs), 0)
+        self.assertEqual(len(strategy_delete_repo.repos), 1)
+
+        # Test delete older revision strategy
+        strategy_delete_revision = report.delete_revisions(OLDER_REVISION)
+        self.assertEqual(
+            strategy_delete_revision.blobs,
+            {file.blob_path for file in older_revision.files},
+        )
+        self.assertEqual(
+            strategy_delete_revision.snapshots, {older_revision.snapshot_path}
+        )
+        self.assertEqual(len(strategy_delete_revision.refs), 0)
+        self.assertEqual(len(strategy_delete_revision.repos), 0)
+        strategy_delete_revision.execute()  # Execute without error
diff --git a/tests/test_utils_cache.py b/tests/test_utils_cache.py
index 3ceae955..7a17bc07 100644
--- a/tests/test_utils_cache.py
+++ b/tests/test_utils_cache.py
@@ -3,13 +3,10 @@ import shutil
 import time
 import unittest
 from pathlib import Path
-from tempfile import TemporaryDirectory
-from typing import Generator
 from unittest.mock import Mock
 
 import pytest
 
-from _pytest.fixtures import SubRequest
 from huggingface_hub._snapshot_download import snapshot_download
 from huggingface_hub.commands.scan_cache import ScanCacheCommand
 from huggingface_hub.utils import DeleteCacheStrategy, HFCacheInfo, scan_cache_dir
@@ -32,14 +29,6 @@ REPO_A_OTHER_HASH = "1da18ebd9185d146bcf84e308de53715d97d67d1"
 REPO_A_MAIN_README_BLOB_HASH = "4baf04727c45b660add228b2934001991bd34b29"
 
 
-@pytest.fixture
-def fx_cache_dir(request: SubRequest) -> Generator[None, None, None]:
-    """Add a `cache_dir` attribute pointing to a temporary directory."""
-    with TemporaryDirectory() as cache_dir:
-        request.cls.cache_dir = Path(cache_dir).resolve()
-        yield
-
-
 @pytest.mark.usefixtures("fx_cache_dir")
 class TestMissingCacheUtils(unittest.TestCase):
     cache_dir: Path