Repository: huggingface/huggingface_hub

Issue:
Opt-in for downloading without symlinks
This is possibly a niche use case.

I recently found that some libraries (`coremltools`, in this case) don't play nice with symlinks even on Unix platforms 😲. This led me to replace this one-liner, which was intended for user communication:

```Python
from huggingface_hub import snapshot_download

repo_id = "apple/coreml-stable-diffusion-v1-4"
variant = "original/packages"

downloaded = snapshot_download(repo_id, allow_patterns=f"{variant}/*")
```

With this one (taken from [the blog post](https://huggingface.co/blog/diffusers-coreml#download-the-model-checkpoints)):

```Python
from huggingface_hub import snapshot_download
from huggingface_hub.file_download import repo_folder_name
from pathlib import Path
import shutil

repo_id = "apple/coreml-stable-diffusion-v1-4"
variant = "original/packages"

def download_model(repo_id, variant, output_dir):
    destination = Path(output_dir) / (repo_id.split("/")[-1] + "_" + variant.replace("/", "_"))
    if destination.exists():
        raise Exception(f"Model already exists at {destination}")
    
    # Download and copy without symlinks
    downloaded = snapshot_download(repo_id, allow_patterns=f"{variant}/*", cache_dir=output_dir)
    downloaded_bundle = Path(downloaded) / variant
    shutil.copytree(downloaded_bundle, destination)

    # Remove all downloaded files
    cache_folder = Path(output_dir) / repo_folder_name(repo_id=repo_id, repo_type="model")
    shutil.rmtree(cache_folder)
    return destination

model_path = download_model(repo_id, variant, output_dir="./models")
print(f"Model downloaded at {model_path}")
```

It's not the end of the world, but in this case I _really_ wanted to stress how easy it was to download Core ML checkpoints from the hub and use them downstream for whatever purpose.

If this is something that only affects `coremltools`, then it's not worthwhile doing anything (I'll open a PR there when I look into the problem in more depth). I'm raising the issue in case somebody else has observed other use cases that could benefit from a flag to unconditionally use #1067 even if symlinks are supported by the underlying os.



Discussion:
Hi @pcuenca , thanks for opening the issue. As you said, this is indeed a quite niche situation. It remind me a [discussion](https://huggingface.slack.com/archives/C02EMARJ65P/p1667811482897489?thread_ts=1667809230.708959&cid=C02EMARJ65P) (internal link) triggered by @philschmid when he wanted to download a model from the Hub without the cache structure (e.g. the blobs and symlinks) in order to build docker containers (cc @julien-c as well).

The solution you proposed is quite good. It would just require to make sure the cache directory is not populated before `download_model` as it is completely erased by `shutil.rmtree` (e.g. users need to know what they are doing :smile:).

About having a flag to disable smylinks (and activate https://github.com/huggingface/huggingface_hub/pull/1067), I'm not against it. I would just wait for more requests before making it a feature of `hfh`.
Yeah, I supposed there might be other scenarios where users need a exact copy of the file structure. Building a docker container sounds like one of them (or deployment tasks, in general). `snapshot_download` is much better than `git clone` because you can specify branches or patterns (as in my example above) and don't have to download stuff you don't need or keep a humongous `.git` directory with all the lfs blobs. Admittedly, we tend to create heavily overloaded repos with multiple variants for different frameworks, floating-point precision, etc.

Happy to propose a PR if you do decide to go this way.

I'm having the same issue.

Our ML platform syncs the HF cache folder to S3. Due to the use of symlinks, this doesn’t work correctly. We'd love it if HF allowed disabling symlinks for the cache. 

Otherwise, using HF by all of our users will require workarounds that are hard to discover and use.

**UPDATE:** After giving it more thought, I realized that it's better to resolve this use-case without changing the implementation of the cache but rather by adding a `save_dir` argument to the `from_pretrained` function that would automatically save the model to the given folder (without using symlinks).
Hmm, I'm still not against doing so (e.g. not using symlinks when downloading/caching some files from the Hub).

The only problem I have with it is that with the current cache, symlinks allow to have a same file shared between several revisions of the same repo. The workaround we built in https://github.com/huggingface/huggingface_hub/pull/1067 is to copy files when symlinks are not supported. It is ok-ish in the sense that "it works" but with a really degraded disk space efficiency. It was ok because the issue was raised for "non-admin-non-dev Windows users" who will not play a lot with different versions of a repo.

Now if we make this feature available as a "normal usage", I'm a bit more skeptical. Let's say a user downloads a model of 10GB and then the readme gets updated by the author. Next time the user wants to load the model, it will be re-downloaded even though only the readme was updated. Furthermore, the model weights will be duplicated on disk for each edit of any file of the repo. Once again, this is not too problematic in case of students in a course but for real development it's different.


Otherwise yes, we could have a method to download to an arbitrary folder and without any versioning. I'm a bit worried that if we do such a thing, some integrations will be messy (not versioning means no caching, no sharing between libs, no way to scan/delete the cache,...)

@julien-c do you have an opinion here ?
Opening up again this issue after some talks with @philschmid (for docker-related purposes). I think the best way to tackle this is to allow users to download stuff independently from the cache-system. It feels safer than playing with a "disable_symlinks" flag.

What I propose is to add a new parameter "destination" to both `hf_hub_download` and `snapshot_download`. When `destination` is set, `cache_dir` must explicitly be set to `False`.

```py
# Download checkpoints/ folder without cache
snapshot_download("my-cool-model", allow_patterns="checkpoints/**", cache_dir=False, destination="/path/to/local/folder")

# Download model weights folder without cache
hf_hub_download("my-cool-model", "pytorch_model.bin", cache_dir=False, destination="/path/to/local/folder")
```

Having `cache_dir` set to False is a bit a redundant information to provide but I think it makes it very clear to the user that there will not be any caching done. And `cache_dir=True` would have no meaning (raise exception in that case).

WDYT @philschmid @pcuenca @patrickvonplaten?
@julien-c I already have an intuition that you will not like the idea (:smile:) but making it explicit should prevent misuse from users, isn't it? At least I prefer this solution than passing environment variables.


---

**EDIT:** Actually, we can still use the internal cache just in case the entry exist. If it does, we do a simple shutil.copyfile instead of re-downloading. But if entry is not there, we don't update the cache.
> EDIT: Actually, maybe we should still use the internal cache just in case the entry exist. If it does, we do a simple shutil.copyfile instead of re-downloading. But if entry is not there, we don't update the cache.

IMO the suggestion above is a good one: the user has full control, and when it makes sense, the cache still can be leveraged.
what about still using the cache (unless another flag is passed) but copying (or .... symlinking?) to `destination` at the end?
Ok, let forget about about the clunky `cache_dir=False` that I suggested and add a `use_symlinks` input flag instead (default True):
- if `destination` is set + `use_symlinks=True` => blobs are downloaded in the cache directory + symlinked to the destination folder
- if `destination` is set + `use_symlinks=False`
    - if blob is already in cache directory => we copy-paste it in the destination directory
    - if blob is not cached => we download it directly to the destination directory. If user does this in multiple locations, the file will be downloaded multiple times but it would be a user's choice
- if `destination` is not set + `use_symlinks=False` => raise ValueError (doesn't make sense)

Seems to be a good compromise for everyone, right?
i would personally say that'd be reasonable^
@Wauplin that sounds reasonable to me too.

Resolving patch:
diff --git a/src/huggingface_hub/_snapshot_download.py b/src/huggingface_hub/_snapshot_download.py
--- a/src/huggingface_hub/_snapshot_download.py
+++ b/src/huggingface_hub/_snapshot_download.py
@@ -15,6 +15,7 @@
 from .hf_api import HfApi
 from .utils import filter_repo_objects, logging, validate_hf_hub_args
 from .utils import tqdm as hf_tqdm
+from .utils._typing import Literal
 
 
 logger = logging.get_logger(__name__)
@@ -27,6 +28,8 @@ def snapshot_download(
     revision: Optional[str] = None,
     repo_type: Optional[str] = None,
     cache_dir: Union[str, Path, None] = None,
+    local_dir: Union[str, Path, None] = None,
+    local_dir_use_symlinks: Union[bool, Literal["auto"]] = "auto",
     library_name: Optional[str] = None,
     library_version: Optional[str] = None,
     user_agent: Optional[Union[Dict, str]] = None,
@@ -40,15 +43,30 @@ def snapshot_download(
     max_workers: int = 8,
     tqdm_class: Optional[base_tqdm] = None,
 ) -> str:
-    """Download all files of a repo.
-
-    Downloads a whole snapshot of a repo's files at the specified revision. This
-    is useful when you want all files from a repo, because you don't know which
-    ones you will need a priori. All files are nested inside a folder in order
-    to keep their actual filename relative to that folder.
-
-    An alternative would be to just clone a repo but this would require that the
-    user always has git and git-lfs installed, and properly configured.
+    """Download repo files.
+
+    Download a whole snapshot of a repo's files at the specified revision. This is useful when you want all files from
+    a repo, because you don't know which ones you will need a priori. All files are nested inside a folder in order
+    to keep their actual filename relative to that folder. You can also filter which files to download using
+    `allow_patterns` and `ignore_patterns`.
+
+    If `local_dir` is provided, the file structure from the repo will be replicated in this location. You can configure
+    how you want to move those files:
+      - If `local_dir_use_symlinks="auto"` (default), files are downloaded and stored in the cache directory as blob
+        files. Small files (<5MB) are duplicated in `local_dir` while a symlink is created for bigger files. The goal
+        is to be able to manually edit and save small files without corrupting the cache while saving disk space for
+        binary files. The 5MB threshold can be configured with the `HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD`
+        environment variable.
+      - If `local_dir_use_symlinks=True`, files are downloaded, stored in the cache directory and symlinked in `local_dir`.
+        This is optimal in term of disk usage but files must not be manually edited.
+      - If `local_dir_use_symlinks=False` and the blob files exist in the cache directory, they are duplicated in the
+        local dir. This means disk usage is not optimized.
+      - Finally, if `local_dir_use_symlinks=False` and the blob files do not exist in the cache directory, then the
+        files are downloaded and directly placed under `local_dir`. This means if you need to download them again later,
+        they will be re-downloaded entirely.
+
+    An alternative would be to clone the repo but this requires git and git-lfs to be installed and properly
+    configured. It is also not possible to filter which files to download when cloning a repository using git.
 
     Args:
         repo_id (`str`):
@@ -61,6 +79,14 @@ def snapshot_download(
             `None` or `"model"` if downloading from a model. Default is `None`.
         cache_dir (`str`, `Path`, *optional*):
             Path to the folder where cached files are stored.
+        local_dir (`str` or `Path`, *optional*:
+            If provided, the downloaded files will be placed under this directory, either as symlinks (default) or
+            regular files (see description for more details).
+        local_dir_use_symlinks (`"auto"` or `bool`, defaults to `"auto"`):
+            To be used with `local_dir`. If set to "auto", the cache directory will be used and the file will be either
+            duplicated or symlinked to the local directory depending on its size. It set to `True`, a symlink will be
+            created, no matter the file size. If set to `False`, the file will either be duplicated from cache (if
+            already exists) or downloaded from the Hub and not cached. See description for more details.
         library_name (`str`, *optional*):
             The name of the library to which the object corresponds.
         library_version (`str`, *optional*):
@@ -189,6 +215,8 @@ def _inner_hf_hub_download(repo_file: str):
             repo_type=repo_type,
             revision=commit_hash,
             cache_dir=cache_dir,
+            local_dir=local_dir,
+            local_dir_use_symlinks=local_dir_use_symlinks,
             library_name=library_name,
             library_version=library_version,
             user_agent=user_agent,
@@ -213,4 +241,6 @@ def _inner_hf_hub_download(repo_file: str):
             tqdm_class=tqdm_class or hf_tqdm,
         )
 
+    if local_dir is not None:
+        return str(os.path.realpath(local_dir))
     return snapshot_folder
diff --git a/src/huggingface_hub/constants.py b/src/huggingface_hub/constants.py
--- a/src/huggingface_hub/constants.py
+++ b/src/huggingface_hub/constants.py
@@ -15,10 +15,10 @@ def _is_true(value: Optional[str]) -> bool:
     return value.upper() in ENV_VARS_TRUE_VALUES
 
 
-def _is_true_or_auto(value: Optional[str]) -> bool:
+def _as_int(value: Optional[str]) -> Optional[int]:
     if value is None:
-        return False
-    return value.upper() in ENV_VARS_TRUE_AND_AUTO_VALUES
+        return None
+    return int(value)
 
 
 # Constants for file downloads
@@ -118,3 +118,11 @@ def _is_true_or_auto(value: Optional[str]) -> bool:
 # - https://pypi.org/project/hf-transfer/
 # - https://github.com/huggingface/hf_transfer (private)
 HF_HUB_ENABLE_HF_TRANSFER: bool = _is_true(os.environ.get("HF_HUB_ENABLE_HF_TRANSFER"))
+
+
+# Used if download to `local_dir` and `local_dir_use_symlinks="auto"`
+# Files smaller than 5MB are copy-pasted while bigger files are symlinked. The idea is to save disk-usage by symlinking
+# huge files (i.e. LFS files most of the time) while allowing small files to be manually edited in local folder.
+HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD: int = (
+    _as_int(os.environ.get("HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD")) or 5 * 1024 * 1024
+)
diff --git a/src/huggingface_hub/file_download.py b/src/huggingface_hub/file_download.py
--- a/src/huggingface_hub/file_download.py
+++ b/src/huggingface_hub/file_download.py
@@ -64,7 +64,7 @@
 )
 from .utils._headers import _http_user_agent
 from .utils._runtime import _PY_VERSION  # noqa: F401 # for backward compatibility
-from .utils._typing import HTTP_METHOD_T
+from .utils._typing import HTTP_METHOD_T, Literal
 
 
 logger = logging.get_logger(__name__)
@@ -72,6 +72,7 @@
 # Regex to get filename from a "Content-Disposition" header for CDN-served files
 HEADER_FILENAME_PATTERN = re.compile(r'filename="(?P<filename>.*?)";')
 
+
 _are_symlinks_supported_in_dir: Dict[str, bool] = {}
 
 
@@ -844,11 +845,19 @@ def _create_relative_symlink(src: str, dst: str, new_blob: bool = False) -> None
     except OSError:
         pass
 
-    cache_dir = os.path.dirname(os.path.commonpath([src, dst]))
-    if are_symlinks_supported(cache_dir=cache_dir):
-        relative_src = os.path.relpath(src, start=os.path.dirname(dst))
+    try:
+        _support_symlinks = are_symlinks_supported(
+            os.path.dirname(os.path.commonpath([os.path.realpath(src), os.path.realpath(dst)]))
+        )
+    except PermissionError:
+        # Permission error means src and dst are not in the same volume (e.g. destination path has been provided
+        # by the user via `local_dir`. Let's test symlink support there)
+        _support_symlinks = are_symlinks_supported(os.path.dirname(dst))
+
+    if _support_symlinks:
+        logger.info(f"Creating pointer from {src} to {dst}")
         try:
-            os.symlink(relative_src, dst)
+            os.symlink(src, dst)
         except FileExistsError:
             if os.path.islink(dst) and os.path.realpath(dst) == os.path.realpath(src):
                 # `dst` already exists and is a symlink to the `src` blob. It is most
@@ -861,8 +870,10 @@ def _create_relative_symlink(src: str, dst: str, new_blob: bool = False) -> None
                 # blob file. Raise exception.
                 raise
     elif new_blob:
+        logger.info(f"Symlink not supported. Moving file from {src} to {dst}")
         os.replace(src, dst)
     else:
+        logger.info(f"Symlink not supported. Copying file from {src} to {dst}")
         shutil.copyfile(src, dst)
 
 
@@ -904,6 +915,8 @@ def hf_hub_download(
     library_name: Optional[str] = None,
     library_version: Optional[str] = None,
     cache_dir: Union[str, Path, None] = None,
+    local_dir: Union[str, Path, None] = None,
+    local_dir_use_symlinks: Union[bool, Literal["auto"]] = "auto",
     user_agent: Union[Dict, str, None] = None,
     force_download: bool = False,
     force_filename: Optional[str] = None,
@@ -926,6 +939,21 @@ def hf_hub_download(
           that have been resolved at that particular commit. Each filename is a symlink to the blob
           at that particular commit.
 
+    If `local_dir` is provided, the file structure from the repo will be replicated in this location. You can configure
+    how you want to move those files:
+      - If `local_dir_use_symlinks="auto"` (default), files are downloaded and stored in the cache directory as blob
+        files. Small files (<5MB) are duplicated in `local_dir` while a symlink is created for bigger files. The goal
+        is to be able to manually edit and save small files without corrupting the cache while saving disk space for
+        binary files. The 5MB threshold can be configured with the `HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD`
+        environment variable.
+      - If `local_dir_use_symlinks=True`, files are downloaded, stored in the cache directory and symlinked in `local_dir`.
+        This is optimal in term of disk usage but files must not be manually edited.
+      - If `local_dir_use_symlinks=False` and the blob files exist in the cache directory, they are duplicated in the
+        local dir. This means disk usage is not optimized.
+      - Finally, if `local_dir_use_symlinks=False` and the blob files do not exist in the cache directory, then the
+        files are downloaded and directly placed under `local_dir`. This means if you need to download them again later,
+        they will be re-downloaded entirely.
+
     ```
     [  96]  .
     └── [ 160]  models--julien-c--EsperBERTo-small
@@ -963,6 +991,14 @@ def hf_hub_download(
             The version of the library.
         cache_dir (`str`, `Path`, *optional*):
             Path to the folder where cached files are stored.
+        local_dir (`str` or `Path`, *optional*):
+            If provided, the downloaded file will be placed under this directory, either as a symlink (default) or
+            a regular file (see description for more details).
+        local_dir_use_symlinks (`"auto"` or `bool`, defaults to `"auto"`):
+            To be used with `local_dir`. If set to "auto", the cache directory will be used and the file will be either
+            duplicated or symlinked to the local directory depending on its size. It set to `True`, a symlink will be
+            created, no matter the file size. If set to `False`, the file will either be duplicated from cache (if
+            already exists) or downloaded from the Hub and not cached. See description for more details.
         user_agent (`dict`, `str`, *optional*):
             The user-agent info in the form of a dictionary or a string.
         force_download (`bool`, *optional*, defaults to `False`):
@@ -1056,6 +1092,8 @@ def hf_hub_download(
         revision = DEFAULT_REVISION
     if isinstance(cache_dir, Path):
         cache_dir = str(cache_dir)
+    if isinstance(local_dir, Path):
+        local_dir = str(local_dir)
 
     if subfolder == "":
         subfolder = None
@@ -1079,6 +1117,8 @@ def hf_hub_download(
     if REGEX_COMMIT_HASH.match(revision):
         pointer_path = os.path.join(storage_folder, "snapshots", revision, relative_filename)
         if os.path.exists(pointer_path):
+            if local_dir is not None:
+                return _to_local_dir(pointer_path, local_dir, relative_filename, use_symlinks=local_dir_use_symlinks)
             return pointer_path
 
     url = hf_hub_url(repo_id, filename, repo_type=repo_type, revision=revision)
@@ -1172,10 +1212,13 @@ def hf_hub_download(
         if commit_hash is not None:
             pointer_path = os.path.join(storage_folder, "snapshots", commit_hash, relative_filename)
             if os.path.exists(pointer_path):
+                if local_dir is not None:
+                    return _to_local_dir(
+                        pointer_path, local_dir, relative_filename, use_symlinks=local_dir_use_symlinks
+                    )
                 return pointer_path
 
-        # If we couldn't find an appropriate file on disk,
-        # raise an error.
+        # If we couldn't find an appropriate file on disk, raise an error.
         # If files cannot be found and local_files_only=True,
         # the models might've been found if local_files_only=False
         # Notify the user about that
@@ -1206,13 +1249,17 @@ def hf_hub_download(
     _cache_commit_hash_for_specific_revision(storage_folder, revision, commit_hash)
 
     if os.path.exists(pointer_path) and not force_download:
+        if local_dir is not None:
+            return _to_local_dir(pointer_path, local_dir, relative_filename, use_symlinks=local_dir_use_symlinks)
         return pointer_path
 
     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, new_blob=False)
-        return pointer_path
+        if local_dir is not None:  # to local dir
+            return _to_local_dir(blob_path, local_dir, relative_filename, use_symlinks=local_dir_use_symlinks)
+        else:  # or in snapshot cache
+            _create_relative_symlink(blob_path, pointer_path, new_blob=False)
+            return pointer_path
 
     # Prevent parallel downloads of the same file with a lock.
     lock_path = blob_path + ".lock"
@@ -1263,11 +1310,31 @@ def _resumable_file_manager() -> Generator[io.BufferedWriter, None, None]:
                 headers=headers,
             )
 
-        logger.info("storing %s in cache at %s", url, blob_path)
-        _chmod_and_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, new_blob=True)
+        if local_dir is None:
+            logger.info(f"Storing {url} in cache at {blob_path}")
+            _chmod_and_replace(temp_file.name, blob_path)
+            _create_relative_symlink(blob_path, pointer_path, new_blob=True)
+        else:
+            local_dir_filepath = os.path.join(local_dir, relative_filename)
+            os.makedirs(os.path.dirname(local_dir_filepath), exist_ok=True)
+
+            # If "auto" (default) copy-paste small files to ease manual editing but symlink big files to save disk
+            # In both cases, blob file is cached.
+            is_big_file = os.stat(temp_file.name).st_size > constants.HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD
+            if local_dir_use_symlinks is True or (local_dir_use_symlinks == "auto" and is_big_file):
+                logger.info(f"Storing {url} in cache at {blob_path}")
+                _chmod_and_replace(temp_file.name, blob_path)
+                logger.info("Create symlink to local dir")
+                _create_relative_symlink(blob_path, local_dir_filepath, new_blob=False)
+            elif local_dir_use_symlinks == "auto" and not is_big_file:
+                logger.info(f"Storing {url} in cache at {blob_path}")
+                _chmod_and_replace(temp_file.name, blob_path)
+                logger.info("Duplicate in local dir (small file and use_symlink set to 'auto')")
+                shutil.copyfile(blob_path, local_dir_filepath)
+            else:
+                logger.info(f"Storing {url} in local_dir at {local_dir_filepath} (not cached).")
+                _chmod_and_replace(temp_file.name, local_dir_filepath)
+            pointer_path = local_dir_filepath  # for return value
 
     try:
         os.remove(lock_path)
@@ -1459,3 +1526,25 @@ def _chmod_and_replace(src: str, dst: str) -> None:
         tmp_file.unlink()
 
     os.replace(src, dst)
+
+
+def _to_local_dir(
+    path: str, local_dir: str, relative_filename: str, use_symlinks: Union[bool, Literal["auto"]]
+) -> str:
+    """Place a file in a local dir (different than cache_dir).
+
+    Either symlink to blob file in cache or duplicate file depending on `use_symlinks` and file size.
+    """
+    local_dir_filepath = os.path.join(local_dir, relative_filename)
+    os.makedirs(os.path.dirname(local_dir_filepath), exist_ok=True)
+    real_blob_path = os.path.realpath(path)
+
+    # If "auto" (default) copy-paste small files to ease manual editing but symlink big files to save disk
+    if use_symlinks == "auto":
+        use_symlinks = os.stat(real_blob_path).st_size > constants.HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD
+
+    if use_symlinks:
+        _create_relative_symlink(real_blob_path, local_dir_filepath, new_blob=False)
+    else:
+        shutil.copyfile(real_blob_path, local_dir_filepath)
+    return local_dir_filepath


Test patch:
diff --git a/tests/test_cache_layout.py b/tests/test_cache_layout.py
--- a/tests/test_cache_layout.py
+++ b/tests/test_cache_layout.py
@@ -120,7 +120,6 @@ def test_no_exist_file_is_cached(self):
 
     def test_file_download_happens_once(self):
         # Tests that a file is only downloaded once if it's not updated.
-
         with SoftTemporaryDirectory() as cache:
             path = hf_hub_download(MODEL_IDENTIFIER, "file_0.txt", cache_dir=cache)
             creation_time_0 = os.path.getmtime(path)
diff --git a/tests/test_file_download.py b/tests/test_file_download.py
--- a/tests/test_file_download.py
+++ b/tests/test_file_download.py
@@ -21,6 +21,7 @@
 import pytest
 import requests
 
+import huggingface_hub.file_download
 from huggingface_hub import HfApi
 from huggingface_hub.constants import (
     CONFIG_NAME,
@@ -45,9 +46,8 @@
     RevisionNotFoundError,
     SoftTemporaryDirectory,
 )
-from tests.testing_constants import TOKEN
 
-from .testing_constants import ENDPOINT_STAGING, OTHER_TOKEN
+from .testing_constants import ENDPOINT_STAGING, OTHER_TOKEN, TOKEN
 from .testing_utils import (
     DUMMY_MODEL_ID,
     DUMMY_MODEL_ID_PINNED_SHA1,
@@ -489,6 +489,164 @@ def test_get_hf_file_metadata_from_a_lfs_file(self) -> None:
         self.assertEqual(metadata.size, 497933648)  # Size of LFS file, not pointer
 
 
+@with_production_testing
+@pytest.mark.usefixtures("fx_cache_dir")
+class HfHubDownloadToLocalDir(unittest.TestCase):
+    cache_dir: Path
+
+    def test_with_local_dir_and_symlinks_and_file_cached(self) -> None:
+        # File already cached
+        hf_hub_download(DUMMY_MODEL_ID, filename=CONFIG_NAME, cache_dir=self.cache_dir)
+
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            returned_path = hf_hub_download(
+                DUMMY_MODEL_ID,
+                filename=CONFIG_NAME,
+                cache_dir=self.cache_dir,
+                local_dir=local_dir,
+                local_dir_use_symlinks=True,
+            )
+            config_file = Path(local_dir) / CONFIG_NAME
+            self.assertEqual(returned_path, str(config_file))
+            self.assertTrue(config_file.is_file())
+            self.assertTrue(  # File is symlink (except in Windows CI)
+                config_file.is_symlink() if os.name != "nt" else not config_file.is_symlink()
+            )
+
+    def test_with_local_dir_and_symlinks_and_file_not_cached(self) -> None:
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            returned_path = hf_hub_download(
+                DUMMY_MODEL_ID,
+                filename=CONFIG_NAME,
+                cache_dir=self.cache_dir,
+                local_dir=local_dir,
+                local_dir_use_symlinks=True,
+            )
+            config_file = Path(local_dir) / CONFIG_NAME
+            self.assertEqual(returned_path, str(config_file))
+            self.assertTrue(config_file.is_file())
+            if os.name != "nt":  # File is symlink (except in Windows CI)
+                self.assertTrue(config_file.is_symlink())
+                blob_path = Path(os.readlink(str(config_file)))  # config_file.readlink() not supported on Python3.7
+                self.assertTrue(self.cache_dir in blob_path.parents)  # blob is cached!
+            else:
+                self.assertFalse(config_file.is_symlink())
+
+    def test_with_local_dir_and_no_symlink_and_file_cached(self) -> None:
+        # File already cached
+        hf_hub_download(DUMMY_MODEL_ID, filename=CONFIG_NAME, cache_dir=self.cache_dir)
+
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            with patch.object(
+                huggingface_hub.file_download, "http_get", wraps=huggingface_hub.file_download.http_get
+            ) as mock:
+                returned_path = hf_hub_download(
+                    DUMMY_MODEL_ID,
+                    filename=CONFIG_NAME,
+                    cache_dir=self.cache_dir,
+                    local_dir=local_dir,
+                    local_dir_use_symlinks=False,  # no symlinks
+                )
+                mock.assert_not_called()  # reused file from cache
+
+            config_file = Path(local_dir) / CONFIG_NAME
+            self.assertEqual(returned_path, str(config_file))
+            self.assertTrue(config_file.is_file())
+            self.assertFalse(config_file.is_symlink())
+
+    def test_with_local_dir_and_no_symlink_and_file_not_cached(self) -> None:
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            with patch.object(
+                huggingface_hub.file_download, "http_get", wraps=huggingface_hub.file_download.http_get
+            ) as mock:
+                returned_path = hf_hub_download(
+                    DUMMY_MODEL_ID,
+                    filename=CONFIG_NAME,
+                    cache_dir=self.cache_dir,
+                    local_dir=local_dir,
+                    local_dir_use_symlinks=False,  # no symlinks
+                )
+                mock.assert_called()  # no file cached => had to download it
+
+            config_file = Path(local_dir) / CONFIG_NAME
+            self.assertEqual(returned_path, str(config_file))
+            self.assertTrue(config_file.is_file())
+            self.assertFalse(config_file.is_symlink())
+
+            # Cache directory not used (no blobs, no symlinks in it)
+            for path in self.cache_dir.glob("**/blobs/**"):
+                self.assertFalse(path.is_file())
+            for path in self.cache_dir.glob("**/snapshots/**"):
+                self.assertFalse(path.is_file())
+
+    @patch("huggingface_hub.constants.HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD", 1024)
+    def test_with_local_dir_and_auto_symlinks_and_file_cached(self) -> None:
+        # File already cached
+        hf_hub_download(DUMMY_MODEL_ID, filename=CONFIG_NAME, cache_dir=self.cache_dir)  # 496 bytes -> small
+        hf_hub_download(DUMMY_MODEL_ID, filename="README.md", cache_dir=self.cache_dir)  # 1.11kB -> "big"
+
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            config = hf_hub_download(
+                DUMMY_MODEL_ID, filename=CONFIG_NAME, cache_dir=self.cache_dir, local_dir=local_dir
+            )
+            readme = hf_hub_download(
+                DUMMY_MODEL_ID, filename="README.md", cache_dir=self.cache_dir, local_dir=local_dir
+            )
+            self.assertFalse(Path(config).is_symlink())  # 496b => small => duplicated
+            if os.name != "nt":
+                self.assertTrue(Path(readme).is_symlink())  # 1.11kB => big => symlink
+
+    @patch("huggingface_hub.constants.HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD", 1024)
+    def test_with_local_dir_and_auto_symlinks_and_file_not_cached(self) -> None:
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            config = hf_hub_download(
+                DUMMY_MODEL_ID, filename=CONFIG_NAME, cache_dir=self.cache_dir, local_dir=local_dir
+            )
+            readme = hf_hub_download(
+                DUMMY_MODEL_ID, filename="README.md", cache_dir=self.cache_dir, local_dir=local_dir
+            )
+            self.assertFalse(Path(config).is_symlink())  # 496b => small => duplicated
+            if os.name != "nt":
+                self.assertTrue(Path(readme).is_symlink())  # 1.11kB => big => symlink
+
+    def test_with_local_dir_and_symlinks_and_overwrite(self) -> None:
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            config_path = Path(local_dir) / CONFIG_NAME
+            config_path.write_text("this will be overwritten")
+            hf_hub_download(
+                DUMMY_MODEL_ID,
+                filename=CONFIG_NAME,
+                cache_dir=self.cache_dir,
+                local_dir=local_dir,
+                local_dir_use_symlinks=True,
+            )
+            if os.name != "nt":
+                self.assertTrue(config_path.is_symlink())
+            self.assertNotEqual(config_path.read_text(), "this will be overwritten")
+
+    def test_with_local_dir_and_no_symlinks_and_overwrite(self) -> None:
+        # Download to local dir
+        with SoftTemporaryDirectory() as local_dir:
+            config_path = Path(local_dir) / CONFIG_NAME
+            config_path.write_text("this will be overwritten")
+            hf_hub_download(
+                DUMMY_MODEL_ID,
+                filename=CONFIG_NAME,
+                cache_dir=self.cache_dir,
+                local_dir=local_dir,
+                local_dir_use_symlinks=False,
+            )
+            self.assertFalse(config_path.is_symlink())
+            self.assertNotEquals(config_path.read_text(), "this will be overwritten")
+
+
 class StagingCachedDownloadTest(unittest.TestCase):
     def test_download_from_a_gated_repo_with_hf_hub_download(self):
         """Checks `hf_hub_download` outputs error on gated repo.
diff --git a/tests/test_snapshot_download.py b/tests/test_snapshot_download.py
--- a/tests/test_snapshot_download.py
+++ b/tests/test_snapshot_download.py
@@ -1,94 +1,69 @@
 import os
 import unittest
+from pathlib import Path
+from unittest.mock import patch
 
 import requests
 
-from huggingface_hub import HfApi, Repository, snapshot_download
-from huggingface_hub.utils import (
-    HfFolder,
-    RepositoryNotFoundError,
-    SoftTemporaryDirectory,
-    logging,
-)
+from huggingface_hub import CommitOperationAdd, HfApi, snapshot_download
+from huggingface_hub.utils import HfFolder, SoftTemporaryDirectory
 
-from .testing_constants import ENDPOINT_STAGING, TOKEN, USER
-from .testing_utils import (
-    expect_deprecation,
-    repo_name,
-    retry_endpoint,
-    rmtree_with_retry,
-)
-
-
-logger = logging.get_logger(__name__)
-
-REPO_NAME = repo_name("dummy-hf-hub")
+from .testing_constants import TOKEN
+from .testing_utils import repo_name
 
 
 class SnapshotDownloadTests(unittest.TestCase):
-    _api = HfApi(endpoint=ENDPOINT_STAGING, token=TOKEN)
-
     @classmethod
-    @retry_endpoint
-    @expect_deprecation("set_access_token")
     def setUpClass(cls):
         """
         Share this valid token in all tests below.
         """
-        cls._token = TOKEN
-        cls._api.set_access_token(TOKEN)
-
-        if os.path.exists(REPO_NAME):
-            rmtree_with_retry(REPO_NAME)
-        logger.info(f"Does {REPO_NAME} exist: {os.path.exists(REPO_NAME)}")
-
-        try:
-            cls._api.delete_repo(repo_id=REPO_NAME)
-        except RepositoryNotFoundError:
-            pass
-        cls._api.create_repo(f"{USER}/{REPO_NAME}")
-
-        repo = Repository(
-            REPO_NAME,
-            clone_from=f"{USER}/{REPO_NAME}",
-            use_auth_token=cls._token,
-            git_user="ci",
-            git_email="ci@dummy.com",
-        )
-
-        with repo.commit("Add file to main branch"):
-            with open("dummy_file.txt", "w+") as f:
-                f.write("v1")
-
-        cls.first_commit_hash = repo.git_head_hash()
-
-        with repo.commit("Add file to main branch"):
-            with open("dummy_file.txt", "w+") as f:
-                f.write("v2")
-            with open("dummy_file_2.txt", "w+") as f:
-                f.write("v3")
-
-        cls.second_commit_hash = repo.git_head_hash()
-
-        with repo.commit("Add file to other branch", branch="other"):
-            with open("dummy_file_2.txt", "w+") as f:
-                f.write("v4")
-
-        cls.third_commit_hash = repo.git_head_hash()
+        cls.api = HfApi(token=TOKEN)
+        cls.repo_id = cls.api.create_repo(repo_name("snapshot-download")).repo_id
+
+        # First commit on `main`
+        cls.first_commit_hash = cls.api.create_commit(
+            repo_id=cls.repo_id,
+            operations=[
+                CommitOperationAdd(path_in_repo="dummy_file.txt", path_or_fileobj=b"v1"),
+                CommitOperationAdd(path_in_repo="subpath/file.txt", path_or_fileobj=b"content in subpath"),
+            ],
+            commit_message="Add file to main branch",
+        ).oid
+
+        # Second commit on `main`
+        cls.second_commit_hash = cls.api.create_commit(
+            repo_id=cls.repo_id,
+            operations=[
+                CommitOperationAdd(path_in_repo="dummy_file.txt", path_or_fileobj=b"v2"),
+                CommitOperationAdd(path_in_repo="dummy_file_2.txt", path_or_fileobj=b"v3"),
+            ],
+            commit_message="Add file to main branch",
+        ).oid
+
+        # Third commit on `other`
+        cls.api.create_branch(repo_id=cls.repo_id, branch="other")
+        cls.third_commit_hash = cls.api.create_commit(
+            repo_id=cls.repo_id,
+            operations=[
+                CommitOperationAdd(path_in_repo="dummy_file_2.txt", path_or_fileobj=b"v4"),
+            ],
+            commit_message="Add file to other branch",
+            revision="other",
+        ).oid
 
     @classmethod
     def tearDownClass(cls) -> None:
-        cls._api.delete_repo(repo_id=REPO_NAME)
-        rmtree_with_retry(REPO_NAME)
+        cls.api.delete_repo(repo_id=cls.repo_id)
 
     def test_download_model(self):
         # Test `main` branch
         with SoftTemporaryDirectory() as tmpdir:
-            storage_folder = snapshot_download(f"{USER}/{REPO_NAME}", revision="main", cache_dir=tmpdir)
+            storage_folder = snapshot_download(self.repo_id, revision="main", cache_dir=tmpdir)
 
             # folder contains the two files contributed and the .gitattributes
             folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 3)
+            self.assertEqual(len(folder_contents), 4)
             self.assertTrue("dummy_file.txt" in folder_contents)
             self.assertTrue("dummy_file_2.txt" in folder_contents)
             self.assertTrue(".gitattributes" in folder_contents)
@@ -103,14 +78,14 @@ def test_download_model(self):
         # Test with specific revision
         with SoftTemporaryDirectory() as tmpdir:
             storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
+                self.repo_id,
                 revision=self.first_commit_hash,
                 cache_dir=tmpdir,
             )
 
             # folder contains the two files contributed and the .gitattributes
             folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 2)
+            self.assertEqual(len(folder_contents), 3)
             self.assertTrue("dummy_file.txt" in folder_contents)
             self.assertTrue(".gitattributes" in folder_contents)
 
@@ -122,197 +97,63 @@ def test_download_model(self):
             self.assertTrue(self.first_commit_hash in storage_folder)
 
     def test_download_private_model(self):
-        self._api.update_repo_visibility(repo_id=REPO_NAME, private=True)
+        self.api.update_repo_visibility(repo_id=self.repo_id, private=True)
 
         # Test download fails without token
         with SoftTemporaryDirectory() as tmpdir:
             with self.assertRaisesRegex(requests.exceptions.HTTPError, "401 Client Error"):
-                _ = snapshot_download(f"{USER}/{REPO_NAME}", revision="main", cache_dir=tmpdir)
+                _ = snapshot_download(self.repo_id, revision="main", cache_dir=tmpdir)
 
         # Test we can download with token from cache
         with SoftTemporaryDirectory() as tmpdir:
-            HfFolder.save_token(self._token)
-            storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision="main",
-                cache_dir=tmpdir,
-                use_auth_token=True,
-            )
-
-            # folder contains the two files contributed and the .gitattributes
-            folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 3)
-            self.assertTrue("dummy_file.txt" in folder_contents)
-            self.assertTrue("dummy_file_2.txt" in folder_contents)
-            self.assertTrue(".gitattributes" in folder_contents)
-
-            with open(os.path.join(storage_folder, "dummy_file.txt"), "r") as f:
-                contents = f.read()
-                self.assertEqual(contents, "v2")
-
-            # folder name contains the revision's commit sha.
+            HfFolder.save_token(TOKEN)
+            storage_folder = snapshot_download(self.repo_id, revision="main", cache_dir=tmpdir)
             self.assertTrue(self.second_commit_hash in storage_folder)
 
         # Test we can download with explicit token
         with SoftTemporaryDirectory() as tmpdir:
-            storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision="main",
-                cache_dir=tmpdir,
-                use_auth_token=self._token,
-            )
-
-            # folder contains the two files contributed and the .gitattributes
-            folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 3)
-            self.assertTrue("dummy_file.txt" in folder_contents)
-            self.assertTrue("dummy_file_2.txt" in folder_contents)
-            self.assertTrue(".gitattributes" in folder_contents)
-
-            with open(os.path.join(storage_folder, "dummy_file.txt"), "r") as f:
-                contents = f.read()
-                self.assertEqual(contents, "v2")
-
-            # folder name contains the revision's commit sha.
+            storage_folder = snapshot_download(self.repo_id, revision="main", cache_dir=tmpdir, token=TOKEN)
             self.assertTrue(self.second_commit_hash in storage_folder)
 
-        self._api.update_repo_visibility(repo_id=REPO_NAME, private=False)
+        self.api.update_repo_visibility(repo_id=self.repo_id, private=False)
 
     def test_download_model_local_only(self):
         # Test no branch specified
         with SoftTemporaryDirectory() as tmpdir:
             # first download folder to cache it
-            snapshot_download(f"{USER}/{REPO_NAME}", cache_dir=tmpdir)
-
+            snapshot_download(self.repo_id, cache_dir=tmpdir)
             # now load from cache
-            storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                cache_dir=tmpdir,
-                local_files_only=True,
-            )
-
-            # folder contains the two files contributed and the .gitattributes
-            folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 3)
-            self.assertTrue("dummy_file.txt" in folder_contents)
-            self.assertTrue("dummy_file_2.txt" in folder_contents)
-            self.assertTrue(".gitattributes" in folder_contents)
-
-            with open(os.path.join(storage_folder, "dummy_file.txt"), "r") as f:
-                contents = f.read()
-                self.assertEqual(contents, "v2")
-
-            # folder name contains the revision's commit sha.
-            self.assertTrue(self.second_commit_hash in storage_folder)
+            storage_folder = snapshot_download(self.repo_id, cache_dir=tmpdir, local_files_only=True)
+            self.assertTrue(self.second_commit_hash in storage_folder)  # has expected revision
 
         # Test with specific revision branch
         with SoftTemporaryDirectory() as tmpdir:
             # first download folder to cache it
-            snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision="other",
-                cache_dir=tmpdir,
-            )
-
+            snapshot_download(self.repo_id, revision="other", cache_dir=tmpdir)
             # now load from cache
-            storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision="other",
-                cache_dir=tmpdir,
-                local_files_only=True,
-            )
-
-            # folder contains the two files contributed and the .gitattributes
-            folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 3)
-            self.assertTrue("dummy_file.txt" in folder_contents)
-            self.assertTrue(".gitattributes" in folder_contents)
-
-            with open(os.path.join(storage_folder, "dummy_file.txt"), "r") as f:
-                contents = f.read()
-                self.assertEqual(contents, "v2")
-
-            # folder name contains the revision's commit sha.
-            self.assertTrue(self.third_commit_hash in storage_folder)
+            storage_folder = snapshot_download(self.repo_id, revision="other", cache_dir=tmpdir, local_files_only=True)
+            self.assertTrue(self.third_commit_hash in storage_folder)  # has expected revision
 
         # Test with specific revision hash
         with SoftTemporaryDirectory() as tmpdir:
             # first download folder to cache it
-            snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision=self.first_commit_hash,
-                cache_dir=tmpdir,
-            )
-
+            snapshot_download(self.repo_id, revision=self.first_commit_hash, cache_dir=tmpdir)
             # now load from cache
             storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision=self.first_commit_hash,
-                cache_dir=tmpdir,
-                local_files_only=True,
+                self.repo_id, revision=self.first_commit_hash, cache_dir=tmpdir, local_files_only=True
             )
-
-            # folder contains the two files contributed and the .gitattributes
-            folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 2)
-            self.assertTrue("dummy_file.txt" in folder_contents)
-            self.assertTrue(".gitattributes" in folder_contents)
-
-            with open(os.path.join(storage_folder, "dummy_file.txt"), "r") as f:
-                contents = f.read()
-                self.assertEqual(contents, "v1")
-
-            # folder name contains the revision's commit sha.
-            self.assertTrue(self.first_commit_hash in storage_folder)
+            self.assertTrue(self.first_commit_hash in storage_folder)  # has expected revision
 
     def test_download_model_local_only_multiple(self):
-        # Test `main` branch
-        with SoftTemporaryDirectory() as tmpdir:
-            # download both from branch and from commit
-            snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                cache_dir=tmpdir,
-            )
-
-            snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision=self.first_commit_hash,
-                cache_dir=tmpdir,
-            )
-
         # cache multiple commits and make sure correct commit is taken
         with SoftTemporaryDirectory() as tmpdir:
-            # first download folder to cache it
-            snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                cache_dir=tmpdir,
-            )
-
-            # now load folder from another branch
-            snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                revision="other",
-                cache_dir=tmpdir,
-            )
+            # download folder from main and other to cache it
+            snapshot_download(self.repo_id, cache_dir=tmpdir)
+            snapshot_download(self.repo_id, revision="other", cache_dir=tmpdir)
 
             # now make sure that loading "main" branch gives correct branch
-            storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
-                cache_dir=tmpdir,
-                local_files_only=True,
-            )
-
-            # folder contains the two files contributed and the .gitattributes
-            folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 3)
-            self.assertTrue("dummy_file.txt" in folder_contents)
-            self.assertTrue(".gitattributes" in folder_contents)
-
-            with open(os.path.join(storage_folder, "dummy_file.txt"), "r") as f:
-                contents = f.read()
-                self.assertEqual(contents, "v2")
-
             # folder name contains the 2nd commit sha and not the 3rd
+            storage_folder = snapshot_download(self.repo_id, cache_dir=tmpdir, local_files_only=True)
             self.assertTrue(self.second_commit_hash in storage_folder)
 
     def check_download_model_with_pattern(self, pattern, allow=True):
@@ -322,35 +163,58 @@ def check_download_model_with_pattern(self, pattern, allow=True):
 
         with SoftTemporaryDirectory() as tmpdir:
             storage_folder = snapshot_download(
-                f"{USER}/{REPO_NAME}",
+                self.repo_id,
                 revision="main",
                 cache_dir=tmpdir,
                 allow_patterns=allow_patterns,
                 ignore_patterns=ignore_patterns,
             )
 
-            # folder contains the two files contributed and the .gitattributes
+            # folder contains the three text files but not the .gitattributes
             folder_contents = os.listdir(storage_folder)
-            self.assertEqual(len(folder_contents), 2)
+            self.assertEqual(len(folder_contents), 3)
             self.assertTrue("dummy_file.txt" in folder_contents)
             self.assertTrue("dummy_file_2.txt" in folder_contents)
             self.assertTrue(".gitattributes" not in folder_contents)
 
-            with open(os.path.join(storage_folder, "dummy_file.txt"), "r") as f:
-                contents = f.read()
-                self.assertEqual(contents, "v2")
-
-            # folder name contains the revision's commit sha.
-            self.assertTrue(self.second_commit_hash in storage_folder)
-
     def test_download_model_with_allow_pattern(self):
         self.check_download_model_with_pattern("*.txt")
 
     def test_download_model_with_allow_pattern_list(self):
-        self.check_download_model_with_pattern(["dummy_file.txt", "dummy_file_2.txt"])
+        self.check_download_model_with_pattern(["dummy_file.txt", "dummy_file_2.txt", "subpath/*"])
 
     def test_download_model_with_ignore_pattern(self):
         self.check_download_model_with_pattern(".gitattributes", allow=False)
 
     def test_download_model_with_ignore_pattern_list(self):
         self.check_download_model_with_pattern(["*.git*", "*.pt"], allow=False)
+
+    @patch("huggingface_hub.constants.HF_HUB_LOCAL_DIR_AUTO_SYMLINK_THRESHOLD", 10)  # >10b => "big file"
+    def test_download_to_local_dir(self) -> None:
+        """Download a repository to local dir.
+
+        Cache dir is used and symlinks are adding to local dir. This test is here to check once the normal behavior
+        with snapshot_download. More combinations of cache_dir/local_dir/use_symlinks are tested separately in
+        `test_file_download.py`.
+        """
+        with SoftTemporaryDirectory() as cache_dir:
+            with SoftTemporaryDirectory() as local_dir:
+                returned_path = snapshot_download(self.repo_id, cache_dir=cache_dir, local_dir=local_dir)
+
+                # Files have been downloaded
+                self.assertTrue((Path(local_dir) / "dummy_file.txt").is_file())
+                self.assertTrue((Path(local_dir) / "dummy_file_2.txt").is_file())
+
+                # Files are small so duplicated from cache (no symlinks)
+                self.assertFalse((Path(local_dir) / "dummy_file.txt").is_symlink())  # smaller than 10b => duplicated
+                self.assertFalse((Path(local_dir) / "dummy_file_2.txt").is_symlink())  # smaller than 10b => duplicated
+
+                # File structure is preserved (+check content)
+                subpath_file = Path(local_dir) / "subpath" / "file.txt"
+                self.assertTrue(subpath_file.is_file())
+                self.assertEqual(subpath_file.read_text(), "content in subpath")
+                if os.name != "nt":
+                    self.assertTrue(subpath_file.is_symlink())  # bigger than 10b => symlinked
+
+                # Check returns local dir and not cache dir
+                self.assertEqual(returned_path, local_dir)