Repository: huggingface/huggingface_hub
Pull request #1088 (2022-09-27 14:02:45)

[RFC] Proposal for a way to cache files in downstream libraries
This is a proposal following discussions started with the `datasets` team (@lhoestq @albertvillanova).

The goal is to have a proper way to cache any kind of files from a downstream library and manage them (e.g.: scan and delete) from `huggingface_hub`. From `hfh`'s perspective, there is not much work to do. We should have a canonical procedure to generate cache paths for a library. Then within a cache folder, the downstream library handles its files as it wants. Once this helper starts to be used, we can adapt the `scan-cache` and `delete-cache` commands. 

I tried to document the `cached_assets_path()` helper to describe the way I see it. Any feedback is welcomed, this is really just a proposal. All the examples are very `datasets`-focused but I think this could benefit to other libraries as `transformers` (@sgugger @LysandreJik ), `diffusers` (@apolinario @patrickvonplaten) or `skops` (@adrinjalali @merveenoyan) to store any kind of intermediate files. IMO the difficulty mainly resides in making the feature used :smile:.

**EDIT:** see [generated documentation here](https://moon-ci-docs.huggingface.co/docs/huggingface_hub/pr_1088/en/package_reference/cache#huggingface_hub.extra_cache_folder).
**EDIT 2:** `assets/` might be a better naming here (common naming in dev)

WDYT ?

(cc @julien-c @osanseviero as well)

Example:
```py
>>> from huggingface_hub import cached_assets_path

>>> cached_assets_path(library_name="datasets", namespace="SQuAD", subfolder="download")
PosixPath('/home/wauplin/.cache/huggingface/extra/datasets/SQuAD/download')

>>> cached_assets_path(library_name="datasets", namespace="SQuAD", subfolder="extracted")
PosixPath('/home/wauplin/.cache/huggingface/extra/datasets/SQuAD/extracted')

>>> cached_assets_path(library_name="datasets", namespace="SQuAD")
PosixPath('/home/wauplin/.cache/huggingface/extra/datasets/SQuAD/default')

>>> cached_assets_path(library_name="datasets", subfolder="modules")
PosixPath('/home/wauplin/.cache/huggingface/extra/datasets/default/modules')

>>> cached_assets_path(library_name="datasets", cache_dir="/tmp/tmp123456")
PosixPath('/tmp/tmp123456/datasets/default/default')
```

And the generated tree:
```text
    assets/
    ├── datasets/
    │   ├── default/
    │   │   ├── modules/
    │   ├── SQuAD/
    │   │   ├── downloaded/
    │   │   ├── extracted/
    │   │   └── processed/
    │   ├── Helsinki-NLP--tatoeba_mt/
    │       ├── downloaded/
    │       ├── extracted/
    │       └── processed/
    └── transformers/
        ├── default/
        │   ├── something/
        ├── bert-base-cased/
        │   ├── default/
        │   └── training/
    hub/
    └── models--julien-c--EsperBERTo-small/
        ├── blobs/
        │   ├── (...)
        │   ├── (...)
        ├── refs/
        │   └── (...)
        └── [ 128]  snapshots/
            ├── 2439f60ef33a0d46d85da5001d52aeda5b00ce9f/
            │   ├── (...)
            └── bbc77c8132af1cc5cf678da3f1ddf2de43606d48/
                └── (...)
```

HuggingFaceDocBuilderDev: The docs for this PR live [here](https://moon-ci-docs.huggingface.co/docs/huggingface_hub/pr_1088). All of your documentation changes will be reflected on that endpoint.
codecov[bot]: # [Codecov](https://codecov.io/gh/huggingface/huggingface_hub/pull/1088?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) Report
Base: **84.86**% // Head: **84.63**% // Decreases project coverage by **`-0.22%`** :warning:
> Coverage data is based on head [(`f74cc45`)](https://codecov.io/gh/huggingface/huggingface_hub/pull/1088?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface) compared to base [(`956a17d`)](https://codecov.io/gh/huggingface/huggingface_hub/commit/956a17dc17a6eca41bc007df3d1def5406bd6b86?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface).
> Patch coverage: 36.84% of modified lines in pull request are covered.

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


```diff
@@            Coverage Diff             @@
##             main    #1088      +/-   ##
==========================================
- Coverage   84.86%   84.63%   -0.23%     
==========================================
  Files          39       40       +1     
  Lines        3958     3977      +19     
==========================================
+ Hits         3359     3366       +7     
- Misses        599      611      +12     
```


| [Impacted Files](https://codecov.io/gh/huggingface/huggingface_hub/pull/1088?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\_extra.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1088/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fY2FjaGVfZXh0cmEucHk=) | `25.00% <25.00%> (ø)` | |
| [src/huggingface\_hub/constants.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1088/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi9jb25zdGFudHMucHk=) | `91.48% <100.00%> (+0.37%)` | :arrow_up: |
| [src/huggingface\_hub/utils/\_\_init\_\_.py](https://codecov.io/gh/huggingface/huggingface_hub/pull/1088/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=huggingface#diff-c3JjL2h1Z2dpbmdmYWNlX2h1Yi91dGlscy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |

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/1088?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).

lhoestq: Cool thanks !

Can it even be possible to have the "datasets" folder one level higher ?
```
    datasets/
    ├── squad/
    │   ├── downloaded/
    │   ├── extracted/
    │   └── arrow/
    hub/
    └── models--julien-c--EsperBERTo-small/
        ├── blobs/
        ├── refs/
        └── [ 128]  snapshots/
```

Then there's "modules" (top level as well) which is the directory where we copy python scripts. IMO this one doesn't need to be part of this change (for now ?)

For context:

The "modules" directory is added to the python path in `datasets` and `evaluate` at run-time once. Inside "modules" we define "datasets_modules" and "evaluate_modules" that can be imported with `importlib.import_module("datasets_modules")` for example. And finally inside "datasets_modules" we have the datasets scripts that are imported as submodules of "datasets_modules".

I think we keep using one directory "modules" at the top level, this way any HF lib only need to add one directory in the python path. For now it can be excluded from the HFH cache since it only contains small files
sgugger: We also use the `modules` folder in the cache to put a `transformers_module` containing the code of dynamic models/tokenizers/pipelines (that have their code on the Hub).
Wauplin: Thanks for the feedback :) To answer your questions 1 by 1:

1.
> Can it even be possible to have the "datasets" folder one level higher ?

What would be the goal of having it at the root of the cache ? The main advantage when I thought about it was that since it is a new folder in the cache, it would not conflict with any of the existing libraries (datasets, transformers and others). In any case, the downstream library shouldn't care too much where the cache it is only a path returned from `hugginface_hub`. But of course, I can be convinced here if it makes sense.

2.
> Then there's "modules" (top level as well) which is the directory where we copy python scripts.

I am fine with keeping the `modules` folder at root level. This folder will in any case be quite small, right ? (only python script so no data to delete to save space). But in next iterations, I don't see huge difference with having them under `cache/extra/datasets/default/modules/` and `cache/extra/transformers/default/modules/` (except not changing the existing code).

3.
> IMO this one doesn't need to be part of this change (for now ?)

In any case, as I see it, the `huggingface_hub` update would just propose a canonical path where to cache stuff. Since stuff is already implemented in `datasets`, `transformers` (and cie), we don't have to change everything at once. Sections of the cache can be iteratively moved to the `extra/` cache but there is no rush in that. 

4.
> The "modules" directory is added to the python path in datasets and evaluate at run-time

Does `datasets` and `evaluate` share the same python module folder ? If (and only if) we move them to the `extra/` cache, we could point `evaluate` to import for the `extra/datasets/modules/` folder since we maintain both librairies. In general I think it's best if each library uses only its own cache but `modules` is a quite specific use case.

5.
> I think we keep using one directory "modules" at the top level, this way any HF lib only need to add one directory in the python path
> We also use the modules folder in the cache to put a transformers_module containing the code of dynamic models/tokenizers/pipelines 

Yep, very specific use case (and already existing) so no need to change IMO.
lhoestq: > What would be the goal of having it at the root of the cache ?

Users of `datasets` that played with the cache before would expect their data to be in `.cache/huggingface/datasets` as before I think - or at least in a directory at the same level with a clear name ("datasets-cache" or something like that).

Therefore if the main concern is to avoid conflicts I think we can just rename it "datasets-cache"

> I am fine with keeping the modules folder at root level. This folder will in any case be quite small, right ?

Yup it's quite small (usually a few KB per dataset)

> Does datasets and evaluate share the same python module folder ?

Both add `.cache/huggingface/modules` in the python path. But in practice they use different folders inside "modules": "datasets_modules" for `datasets` and "evaluate_modules" for `evaluate`. So their caches are different, and each lib handles its cache its own way. And we only need to add one directory to python path.
Wauplin: > Therefore if the main concern is to avoid conflicts I think we can just rename it "datasets-cache"

I would also like to avoid conflicts with existing folders for the future `scan-cache` and `delete-cache` tools. If everything lives under the `extra/` directory, I would not have to care about special folders that would not use the same structure like `modules` or `doc_builder`. For now I only have those 2 examples on my machine but idk if there are more. Having everything in a separate folder `extra/` on which `huggingface_hub` is the only lib to deal with the structure (at least for the first 3 levels of depths, then under `cache/extra/datasets/SQuAD/extracted` you can put anything you want) would avoid edge cases.

Also, we it would allow to have a clear distinction between folders that can be scanned via `huggingface-cli` and the others. If we have `datasets/` and `datasets-cache/` together next to each other I'd find it more confusing actually. WDYT ?

> Yup it's quite small (usually a few KB per dataset)
> Both add .cache/huggingface/modules in the python path

Then let's not focus too much on that. It light-weight and already working so no need to touch it.
Wauplin: (nit) I just thought about it but renaming `extra/` to `assets/` sounds better I'd say (I would expect users/devs to understand it better). And `cached_assets_folder` as helper name. 
lhoestq: Ok I see ! I like `assets` more as well. I'm ok with going for `assets` then (I just remembered we had a quite explicit message printed every time a dataset is loaded to show its cache location anyway, so it should do the job)
polinaeterna: yes this structure is good for us (`datasets`) and it means that scripts are stored not in `modules/datasets_modules` but in a specific dataset folder (like `squad/python`) 

but it would require either adding both `datasets-cache` and `evaluate` folders to python path which might lead to collisions, or higher level `~/.cache/huggingface` directory, but this way it might lead to collision with `transformers`, though the latter seems unlikely because it shouldn't contain `__init__.py`, as far as I understand, so tell me if I'm wrong.   
albertvillanova: Hi @Wauplin,

Indeed yesterday we had a meeting of the datasets team and we discussed about this topic.
IMO, I totally understand your point: so that we keep current behavior of the hub cache and just add the required specificity by datasets with as less impact as possible to current implementation (in a different directory).

The request by @lhoestq and @polinaeterna of putting everything in a per-dataset directory, has an underlying need: to be able to easily find/delete all cached files of a specific dataset:
- with their approach, this can easily be done by listing/removing the corresponding dataset directory
- with your approach, that will be possible by using the scan-cache and delete-cache functions:

Therefore, if we agree on your proposed directory structure:
- can we list/delete all cached files of a specific datasets, regardless of whether the files are in one directory (hub) or another (extra or assets)?

Additionally, for the "modules" directory, I think you were right (@lhoestq could you confirm this?): they need to be in a separate parent directory (that will be added to the import path), for all datasets: `datasets_modules/`. Therefore, not possible to have it in a per-dataset directory (`squad/python/`). Also note that these files are a copy of the previously cached files in your "hub/" directory (scripts are downloaded from the Hub).

Another remark: please note the specificity of the "downloaded" directory:
- until now, we put there whatever file we downloaded, either from the Hub or from 3rd party servers
- with the new implementation, the "downloaded" should only be used for 3rd party servers; files downloaded from the Hub will be in your "hub/" directory
I think this difference must be implemented downstream by datasets team.
lhoestq: I see, thanks @Wauplin and @albertvillanova :) sounds good to me

> Additionally, for the "modules" directory, I think you were right (@lhoestq could you confirm this?): they need to be in a separate parent directory (that will be added to the import path), for all datasets: datasets_modules/. Therefore, not possible to have it in a per-dataset directory (squad/python/). Also note that these files are a copy of the previously cached files in your "hub/" directory (scripts are downloaded from the Hub).

We could also add the `assets` directory to `sys.path`, and name its subdirectory `datasets-cache` to avoid a module name collision with `datasets`, but I don't have a strong opinion on this - we can keep the `modules` dir if we want
Wauplin: > We could also add the assets directory to sys.path, and name its subdirectory datasets-cache to avoid a module name collision with datasets, but I don't have a strong opinion on this - we can keep the modules dir if we want

Not so much opinionated about this but if you do so I think you should align with the `transformers` team as well.
Worth noticing that `assets/` can be managed by any downstream library (e.g. any lib will be able to have its own directory under `assets/`) so maybe it's not ideal to add everything to the python path.
lhoestq: I see - let's keep `modules` as it is then
Wauplin: Ok everyone, thanks for the feedback and discussions. I think we reached a common agreement for this PR then. 
When it comes to the tools to scan/delete those assets, we can discuss them in a separate issue. They will come once we start to integrate this assets folder with `datasets` and other downstream libraries :) 

@LysandreJik @lhoestq I've ping you for the review but other contributors are welcomed to review if wanted. Thanks in advance !
Wauplin: Thanks for the review @LysandreJik , I'm merging :) 

Patch:
diff --git a/docs/source/how-to-cache.mdx b/docs/source/how-to-cache.mdx
index e0872e77..b72b5384 100644
--- a/docs/source/how-to-cache.mdx
+++ b/docs/source/how-to-cache.mdx
@@ -128,6 +128,68 @@ When symlinks are not supported, a warning message is displayed to the user to a
 them they are using a degraded version of the cache-system. This warning can be disabled
 by setting the `HF_HUB_DISABLE_SYMLINKS_WARNING` environment variable to true.
 
+## Caching assets
+
+In addition to caching files from the Hub, downstream libraries often requires to cache
+other files related to HF but not handled directly by `huggingface_hub` (example: file
+downloaded from GitHub, preprocessed data, logs,...). In order to cache those files,
+called `assets`, one can use [`cached_assets_path`]. This small helper generates paths
+in the HF cache in a unified way based on the name of the library requesting it and
+optionally on a namespace and a subfolder name. The goal is to let every downstream
+libraries manage its assets its own way (e.g. no rule on the structure) as long as it
+stays in the right assets folder. Those libraries can then leverage tools from
+`huggingface_hub` to manage the cache, in particular scanning and deleting parts of the
+assets from a CLI command.
+
+```py
+from huggingface_hub import cached_assets_path
+
+assets_path = cached_assets_path(library_name="datasets", namespace="SQuAD", subfolder="download")
+something_path = assets_path / "something.json" # Do anything you like in your assets folder !
+```
+
+<Tip>
+
+[`cached_assets_path`] is the recommended way to store assets but is not mandatory. If
+your library already uses its own cache, feel free to use it!
+
+</Tip>
+
+### Assets in practice
+
+In practice, your assets cache should look like the following tree:
+
+```text
+    assets/
+    └── datasets/
+    │   ├── SQuAD/
+    │   │   ├── downloaded/
+    │   │   ├── extracted/
+    │   │   └── processed/
+    │   ├── Helsinki-NLP--tatoeba_mt/
+    │       ├── downloaded/
+    │       ├── extracted/
+    │       └── processed/
+    └── transformers/
+        ├── default/
+        │   ├── something/
+        ├── bert-base-cased/
+        │   ├── default/
+        │   └── training/
+    hub/
+    └── models--julien-c--EsperBERTo-small/
+        ├── blobs/
+        │   ├── (...)
+        │   ├── (...)
+        ├── refs/
+        │   └── (...)
+        └── [ 128]  snapshots/
+            ├── 2439f60ef33a0d46d85da5001d52aeda5b00ce9f/
+            │   ├── (...)
+            └── bbc77c8132af1cc5cf678da3f1ddf2de43606d48/
+                └── (...)
+```
+
 ## Scan your cache
 
 At the moment, cached files are never deleted from your local directory: when you download
diff --git a/docs/source/package_reference/cache.mdx b/docs/source/package_reference/cache.mdx
index a2da98b8..2fb86bf0 100644
--- a/docs/source/package_reference/cache.mdx
+++ b/docs/source/package_reference/cache.mdx
@@ -6,6 +6,10 @@ for a detailed presentation of caching at HF.
 
 ## Helpers
 
+## cached_assets_path
+
+[[autodoc]] huggingface_hub.cached_assets_path
+
 ### scan_cache_dir
 
 [[autodoc]] huggingface_hub.scan_cache_dir
diff --git a/src/huggingface_hub/__init__.py b/src/huggingface_hub/__init__.py
index f17bf3e3..9c403718 100644
--- a/src/huggingface_hub/__init__.py
+++ b/src/huggingface_hub/__init__.py
@@ -172,6 +172,7 @@ _SUBMOD_ATTRS = {
         "DeleteCacheStrategy",
         "HFCacheInfo",
         "HfFolder",
+        "cached_assets_path",
         "logging",
         "scan_cache_dir",
     ],
@@ -374,6 +375,7 @@ if TYPE_CHECKING:  # pragma: no cover
     from .utils import DeleteCacheStrategy  # noqa: F401
     from .utils import HFCacheInfo  # noqa: F401
     from .utils import HfFolder  # noqa: F401
+    from .utils import cached_assets_path  # noqa: F401
     from .utils import logging  # noqa: F401
     from .utils import scan_cache_dir  # noqa: F401
     from .utils.endpoint_helpers import DatasetFilter  # noqa: F401
diff --git a/src/huggingface_hub/constants.py b/src/huggingface_hub/constants.py
index 2d88db7a..1368aca4 100644
--- a/src/huggingface_hub/constants.py
+++ b/src/huggingface_hub/constants.py
@@ -77,15 +77,20 @@ hf_cache_home = os.path.expanduser(
         os.path.join(os.getenv("XDG_CACHE_HOME", default_home), "huggingface"),
     )
 )
+
 default_cache_path = os.path.join(hf_cache_home, "hub")
+default_assets_cache_path = os.path.join(hf_cache_home, "assets")
 
 HUGGINGFACE_HUB_CACHE = os.getenv("HUGGINGFACE_HUB_CACHE", default_cache_path)
+HUGGINGFACE_ASSETS_CACHE = os.getenv(
+    "HUGGINGFACE_ASSETS_CACHE", default_assets_cache_path
+)
 
 HF_HUB_OFFLINE = _is_true(os.environ.get("HF_HUB_OFFLINE"))
 
 
 # Here, `True` will disable progress bars globally without possibility of enabling it
-# programatically. `False` will enable them without possibility of disabling them.
+# programmatically. `False` will enable them without possibility of disabling them.
 # If environment variable is not set (None), then the user is free to enable/disable
 # them programmatically.
 # TL;DR: env variable has priority over code
diff --git a/src/huggingface_hub/utils/__init__.py b/src/huggingface_hub/utils/__init__.py
index 144b2402..cb2a1222 100644
--- a/src/huggingface_hub/utils/__init__.py
+++ b/src/huggingface_hub/utils/__init__.py
@@ -16,6 +16,7 @@
 # limitations under the License
 
 from . import tqdm as _tqdm  # _tqdm is the module
+from ._cache_assets import cached_assets_path
 from ._cache_manager import (
     CachedFileInfo,
     CachedRepoInfo,
diff --git a/src/huggingface_hub/utils/_cache_assets.py b/src/huggingface_hub/utils/_cache_assets.py
new file mode 100644
index 00000000..467ddaae
--- /dev/null
+++ b/src/huggingface_hub/utils/_cache_assets.py
@@ -0,0 +1,138 @@
+# coding=utf-8
+# Copyright 2019-present, the HuggingFace Inc. team.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from pathlib import Path
+from typing import Union
+
+from ..constants import HUGGINGFACE_ASSETS_CACHE
+
+
+def cached_assets_path(
+    library_name: str,
+    namespace: str = "default",
+    subfolder: str = "default",
+    *,
+    assets_dir: Union[str, Path, None] = None,
+):
+    """Return a folder path to cache arbitrary files.
+
+    `huggingface_hub` provides a canonical folder path to store assets. This is the
+    recommended way to integrate cache in a downstream library as it will benefit from
+    the builtins tools to scan and delete the cache properly.
+
+    The distinction is made between files cached from the Hub and assets. Files from the
+    Hub are cached in a git-aware manner and entirely managed by `huggingface_hub`. See
+    [related documentation](https://huggingface.co/docs/huggingface_hub/how-to-cache).
+    All other files that a downstream library caches are considered to be "assets"
+    (files downloaded from external sources, extracted from a .tar archive, preprocessed
+    for training,...).
+
+    Once the folder path is generated, it is guaranteed to exist and to be a directory.
+    The path is based on 3 levels of depth: the library name, a namespace and a
+    subfolder. Those 3 levels grants flexibility while allowing `huggingface_hub` to
+    expect folders when scanning/deleting parts of the assets cache. Within a library,
+    it is expected that all namespaces share the same subset of subfolder names but this
+    is not a mandatory rule. The downstream library has then full control on which file
+    structure to adopt within its cache. Namespace and subfolder are optional (would
+    default to a `"default/"` subfolder) but library name is mandatory as we want every
+    downstream library to manage its own cache.
+
+    Expected tree:
+    ```text
+        assets/
+        └── datasets/
+        │   ├── SQuAD/
+        │   │   ├── downloaded/
+        │   │   ├── extracted/
+        │   │   └── processed/
+        │   ├── Helsinki-NLP--tatoeba_mt/
+        │       ├── downloaded/
+        │       ├── extracted/
+        │       └── processed/
+        └── transformers/
+            ├── default/
+            │   ├── something/
+            ├── bert-base-cased/
+            │   ├── default/
+            │   └── training/
+        hub/
+        └── models--julien-c--EsperBERTo-small/
+            ├── blobs/
+            │   ├── (...)
+            │   ├── (...)
+            ├── refs/
+            │   └── (...)
+            └── [ 128]  snapshots/
+                ├── 2439f60ef33a0d46d85da5001d52aeda5b00ce9f/
+                │   ├── (...)
+                └── bbc77c8132af1cc5cf678da3f1ddf2de43606d48/
+                    └── (...)
+    ```
+
+
+    Args:
+        library_name (`str`):
+            Name of the library that will manage the cache folder. Example: `"dataset"`.
+        namespace (`str`, *optional*, defaults to "default"):
+            Namespace to which the data belongs. Example: `"SQuAD"`.
+        subfolder (`str`, *optional*, defaults to "default"):
+            Subfolder in which the data will be stored. Example: `extracted`.
+        assets_dir (`str`, `Path`, *optional*):
+            Path to the folder where assets are cached. This must not be the same folder
+            where Hub files are cached. Defaults to `HF_HOME / "assets"` if not provided.
+            Can also be set with `HUGGINGFACE_ASSETS_CACHE` environment variable.
+
+    Returns:
+        Path to the cache folder (`Path`).
+
+    Example:
+    ```py
+    >>> from huggingface_hub import cached_assets_path
+
+    >>> cached_assets_path(library_name="datasets", namespace="SQuAD", subfolder="download")
+    PosixPath('/home/wauplin/.cache/huggingface/extra/datasets/SQuAD/download')
+
+    >>> cached_assets_path(library_name="datasets", namespace="SQuAD", subfolder="extracted")
+    PosixPath('/home/wauplin/.cache/huggingface/extra/datasets/SQuAD/extracted')
+
+    >>> cached_assets_path(library_name="datasets", namespace="Helsinki-NLP/tatoeba_mt")
+    PosixPath('/home/wauplin/.cache/huggingface/extra/datasets/Helsinki-NLP--tatoeba_mt/default')
+
+    >>> cached_assets_path(library_name="datasets", assets_dir="/tmp/tmp123456")
+    PosixPath('/tmp/tmp123456/datasets/default/default')
+    ```
+    """
+    # Resolve assets_dir
+    if assets_dir is None:
+        assets_dir = HUGGINGFACE_ASSETS_CACHE
+    assets_dir = Path(assets_dir).expanduser().resolve()
+
+    # Avoid names that could create path issues
+    for part in (" ", "/", "\\"):
+        library_name = library_name.replace(part, "--")
+        namespace = namespace.replace(part, "--")
+        subfolder = subfolder.replace(part, "--")
+
+    # Path to subfolder is created
+    path = assets_dir / library_name / namespace / subfolder
+    try:
+        path.mkdir(exist_ok=True, parents=True)
+    except (FileExistsError, NotADirectoryError):
+        raise ValueError(
+            "Corrupted assets folder: cannot create directory because of an existing"
+            f" file ({path})."
+        )
+
+    # Return
+    return path


Test patch:
diff --git a/tests/test_utils_assets.py b/tests/test_utils_assets.py
new file mode 100644
index 00000000..1dbae04b
--- /dev/null
+++ b/tests/test_utils_assets.py
@@ -0,0 +1,87 @@
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from huggingface_hub import cached_assets_path
+
+
+@pytest.mark.usefixtures("fx_cache_dir")
+class CacheAssetsTest(unittest.TestCase):
+    cache_dir: Path
+
+    def test_cached_assets_path_with_namespace_and_subfolder(self) -> None:
+        expected_path = self.cache_dir / "datasets" / "SQuAD" / "download"
+        self.assertFalse(expected_path.is_dir())
+
+        path = cached_assets_path(
+            library_name="datasets",
+            namespace="SQuAD",
+            subfolder="download",
+            assets_dir=self.cache_dir,
+        )
+
+        self.assertEqual(path, expected_path)  # Path is generated
+        self.assertTrue(path.is_dir())  # And dir is created
+
+    def test_cached_assets_path_without_subfolder(self) -> None:
+        path = cached_assets_path(
+            library_name="datasets", namespace="SQuAD", assets_dir=self.cache_dir
+        )
+        self.assertEqual(path, self.cache_dir / "datasets" / "SQuAD" / "default")
+        self.assertTrue(path.is_dir())
+
+    def test_cached_assets_path_without_namespace(self) -> None:
+        path = cached_assets_path(
+            library_name="datasets", subfolder="download", assets_dir=self.cache_dir
+        )
+        self.assertEqual(path, self.cache_dir / "datasets" / "default" / "download")
+        self.assertTrue(path.is_dir())
+
+    def test_cached_assets_path_without_namespace_and_subfolder(self) -> None:
+        path = cached_assets_path(library_name="datasets", assets_dir=self.cache_dir)
+        self.assertEqual(path, self.cache_dir / "datasets" / "default" / "default")
+        self.assertTrue(path.is_dir())
+
+    def test_cached_assets_path_forbidden_symbols(self) -> None:
+        path = cached_assets_path(
+            library_name="ReAlLy dumb",
+            namespace="user/repo_name",
+            subfolder="this is/not\\clever",
+            assets_dir=self.cache_dir,
+        )
+        self.assertEqual(
+            path,
+            self.cache_dir
+            / "ReAlLy--dumb"
+            / "user--repo_name"
+            / "this--is--not--clever",
+        )
+        self.assertTrue(path.is_dir())
+
+    def test_cached_assets_path_default_assets_dir(self) -> None:
+        with patch(
+            "huggingface_hub.utils._cache_assets.HUGGINGFACE_ASSETS_CACHE",
+            self.cache_dir,
+        ):  # Uses environment variable from HUGGINGFACE_ASSETS_CACHE
+            self.assertEqual(
+                cached_assets_path(library_name="datasets"),
+                self.cache_dir / "datasets" / "default" / "default",
+            )
+
+    def test_cached_assets_path_is_a_file(self) -> None:
+        expected_path = self.cache_dir / "datasets" / "default" / "default"
+        expected_path.parent.mkdir(parents=True)
+        expected_path.touch()  # this should be the generated folder but is a file !
+
+        with self.assertRaises(ValueError):
+            cached_assets_path(library_name="datasets", assets_dir=self.cache_dir)
+
+    def test_cached_assets_path_parent_is_a_file(self) -> None:
+        expected_path = self.cache_dir / "datasets" / "default" / "default"
+        expected_path.parent.parent.mkdir(parents=True)
+        expected_path.parent.touch()  # cannot create folder as a parent is a file !
+
+        with self.assertRaises(ValueError):
+            cached_assets_path(library_name="datasets", assets_dir=self.cache_dir)