Repository: aeye-lab/pymovements (python)

Issue:
deprecate utils modules that are not intended for end-users
## Description of the problem
Most of our utils modules are not really intended to be used by end-users , but should be used via high-level interfaces that call these methods.

This follows an anti-pattern where there is high coupling between the `utils` module and other modules and reduced cohesion inside modules like `dataset`.

Also this crowds our online documentation with irrelevant functions no end-user will need.

## Description of a solution

Instead, we should increase the cohesion inside each module and reduce coupling between modules.

Candidates are:
- [x] `utils.aois` #1080 
- [x] `utils.archives` -> `dataset._utils.archives` #1028 
- [x] `utils.checks` -> `_utils._checks` #1094 
- [x] remove `utils.decorators` #1008 
- [x] `utils.downloads` -> `dataset._utils.downloads` #1030 
- [x] `utils.filters` -> `events._utils._filters` #1032
- [x] `utils.parsing` -> `gaze._utils.parsing` #976 
- [x] `utils.paths` -> `_utils._paths` #1029 
- [ ] `utils.plotting` -> `plotting._utils.matlab` #1081 
- [x] `utils.strings` -> `_utils._strings` #1031 


Pull request:
depr: deprecate `utils.downloads` in favor of `Dataset.download()`
## :warning: Deprecation

`utils.downloads` is deprecated. please use `Dataset.downloads()` instead.
`utils.downloads` will be removed in pymovements v0.26.0

## Description

Deprecate `utils.downloads` in favor of `Dataset.downloads()`

The following warnings are printed:

```
DeprecationWarning: Call to deprecated function (or staticmethod) download_and_extract_archive. (Please use Dataset.downloads() instead. This module will be removed in v0.26.0.) -- Deprecated since version v0.21.0.
```

```
DeprecationWarning: Call to deprecated function (or staticmethod) download_file. (Please use Dataset.downloads() instead. This module will be removed in v0.26.0.) -- Deprecated since version v0.21.0.
```


partly resolves #999

blocked by #1028 

## Implemented changes

- [x] moved `utils/downloads.py` to `dataset/_utils/downloads.py` and adjusted to new location
- [x] use `dataset._utils.downloads` in `dataset.dataset_download` 
- [x] created deprecated function in `utils/downloads.py`
- [x] moved `tests/unit/utils/downloads_test.py` to `tests/unit/dataset/_utils/_downloads_test.py`
- [x] add test that functions are correctly deprecated
- [x] add test that functions are removed in planned version


Patch:
diff --git a/src/pymovements/dataset/_utils/_downloads.py b/src/pymovements/dataset/_utils/_downloads.py
new file mode 100644
index 0000000..4216f7f
--- /dev/null
+++ b/src/pymovements/dataset/_utils/_downloads.py
@@ -0,0 +1,316 @@
+# Copyright (c) 2022-2025 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Utils module for downloading files."""
+from __future__ import annotations
+
+import hashlib
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+from tqdm.auto import tqdm
+
+from pymovements.dataset._utils._archives import extract_archive
+
+USER_AGENT: str = 'pymovements'
+
+
+def download_and_extract_archive(
+        url: str,
+        download_dirpath: Path,
+        download_filename: str,
+        extract_dirpath: Path | None = None,
+        md5: str | None = None,
+        recursive: bool = True,
+        remove_finished: bool = False,
+        remove_top_level: bool = True,
+        verbose: int = 1,
+) -> None:
+    """Download and extract archive file.
+
+    Parameters
+    ----------
+    url: str
+        URL of archive file to be downloaded.
+    download_dirpath: Path
+        Path to directory where file will be saved to.
+    download_filename: str
+        Target filename of saved file.
+    extract_dirpath: Path | None
+        Path to directory where archive files will be extracted to. (default: None)
+    md5: str | None
+        MD5 checksum of downloaded file. If None, do not check. (default: None)
+    recursive: bool
+        Recursively extract archives which are included in extracted archive. (default: True)
+    remove_finished: bool
+        Remove downloaded file after successful extraction or decompression. (default: False)
+    remove_top_level: bool
+        If ``True``, remove the top-level directory if it has only one child. (default: True)
+    verbose: int
+        Verbosity levels: (1) Show download progress bar and print info messages on downloading
+        and extracting archive files without printing messages for recursive archive extraction.
+        (2) Print additional messages for each recursive archive extract. (default: 1)
+
+    Raises
+    ------
+    RuntimeError
+        If the downloaded file has no suffix or suffix is not supported, or in case of a
+        specified MD5 checksum which doesn't match the checksum of the downloaded file.
+    """
+    archive_path = download_file(
+        url=url,
+        dirpath=download_dirpath,
+        filename=download_filename,
+        md5=md5,
+        verbose=bool(verbose),
+    )
+
+    if extract_dirpath is None:
+        extract_dirpath = download_dirpath
+
+    extract_archive(
+        source_path=archive_path,
+        destination_path=extract_dirpath,
+        recursive=recursive,
+        remove_finished=remove_finished,
+        remove_top_level=remove_top_level,
+        verbose=verbose,
+    )
+
+
+def download_file(
+        url: str,
+        dirpath: Path,
+        filename: str,
+        md5: str | None = None,
+        max_redirect_hops: int = 3,
+        verbose: bool = True,
+) -> Path:
+    """Download a file from a URL and place it in root.
+
+    Parameters
+    ----------
+    url : str
+        URL of file to be downloaded.
+    dirpath : Path
+        Path to directory where file will be saved to.
+    filename : str
+        Target filename of saved file.
+    md5 : str | None
+        MD5 checksum of downloaded file. If None, do not check. (default: None)
+    max_redirect_hops : int
+        Maximum number of redirect hops allowed. (default: 3)
+    verbose : bool
+        If True, show progress bar and print info messages on downloading file. (default: True)
+
+    Returns
+    -------
+    Path
+        Filepath to downloaded file.
+
+    Raises
+    ------
+    OSError
+        If the download process failed.
+    RuntimeError
+        If the MD5 checksum of the downloaded file did not match the expected checksum.
+    """
+    dirpath = dirpath.expanduser()
+    dirpath.mkdir(parents=True, exist_ok=True)
+    filepath = dirpath / filename
+
+    # check if file is already present locally
+    if _check_integrity(filepath, md5):
+        if verbose:
+            print('Using already downloaded and verified file:', filepath)
+        return filepath
+
+    if verbose:
+        print(f'Downloading {url} to {filepath}')
+
+    # expand redirect chain if needed
+    url = _get_redirected_url(url=url, max_hops=max_redirect_hops)
+
+    # download the file
+    try:
+        _download_url(url=url, destination=filepath, verbose=verbose)
+
+    except OSError as e:
+        if url[:5] == 'https':
+            print('Download failed. Trying https -> http instead.')
+            url = url.replace('https:', 'http:')
+
+            if verbose:
+                print(f'Downloading {url} to {filepath}')
+            _download_url(url=url, destination=filepath, verbose=verbose)
+        else:
+            raise e
+
+    # check integrity of downloaded file
+    if verbose:
+        print(f'Checking integrity of {filepath.name}')
+    if not _check_integrity(filepath=filepath, md5=md5):
+        raise RuntimeError(f'File {filepath} not found or download corrupted.')
+
+    return filepath
+
+
+def _get_redirected_url(url: str, max_hops: int = 3) -> str:
+    """Get redirected URL.
+
+    Parameters
+    ----------
+    url: str
+        Initial URL to be requested for redirection.
+    max_hops: int
+        Maximum number of redirection hops. (default: 3)
+
+    Returns
+    -------
+    str
+        Final URL after all redirections.
+
+    Raises
+    ------
+    RuntimeError
+        If number of redirects exceed `max_hops`.
+    """
+    initial_url = url
+    headers = {'Method': 'HEAD', 'User-Agent': USER_AGENT}
+
+    for _ in range(max_hops + 1):
+        with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as response:
+            if response.url == url or response.url is None:
+                return url
+            url = response.url
+
+    raise RuntimeError(
+        f'Request to {initial_url} exceeded {max_hops} redirects.'
+        f' The last redirect points to {url}.',
+    )
+
+
+# have to ignore pylint since tqdm 2.45.0 broke incosistent-mro
+# https://github.com/tqdm/tqdm/blob/0bb91857eca0d4aea08f66cf1c8949abe0cd6b7a/tqdm/auto.py#L27
+class _DownloadProgressBar(tqdm):  # pylint: disable=inconsistent-mro
+    """Progress bar for downloads.
+
+    Provides `update_to(n)` which uses `tqdm.update(delta_n)`.
+
+    Reference: https://github.com/tqdm/tqdm#hooks-and-callbacks
+
+    Parameters
+    ----------
+    **kwargs : Any
+    """
+
+    def __init__(self, **kwargs: Any):
+        super().__init__(unit='B', unit_scale=True, unit_divisor=1024, miniters=1, **kwargs)
+
+    def update_to(self, b: int = 1, bsize: int = 1, tsize: int | None = None) -> bool | None:
+        """Update progress bar.
+
+        Parameters
+        ----------
+        b  : int
+            Number of blocks transferred so far (default: 1).
+        bsize  : int
+            Size of each block (in tqdm units) (default: 1).
+        tsize  : int | None
+            Total size (in tqdm units). If None it remains unchanged (default: None).
+
+        Returns
+        -------
+        bool | None
+            Returns `None` if the update was successful, `False` if the update was skipped
+            because the last update was too recent.
+        """
+        if tsize is not None:
+            self.total = tsize
+        return self.update(b * bsize - self.n)  # also sets self.n = b * bsize
+
+
+def _download_url(url: str, destination: Path, verbose: bool = True) -> None:
+    """Download file from URL and save to destination.
+
+    Parameters
+    ----------
+    url : str
+        URL of file to be downloaded.
+    destination : Path
+        Destination path of downloaded file.
+    verbose : bool
+        If True, show progressbar.
+    """
+    with _DownloadProgressBar(desc=destination.name, disable=not verbose) as t:
+        urllib.request.urlretrieve(url=url, filename=destination, reporthook=t.update_to)
+        t.total = t.n
+
+
+def _check_integrity(filepath: Path, md5: str | None = None) -> bool:
+    """Check file integrity by MD5 checksum.
+
+    Parameters
+    ----------
+    filepath : Path
+        Path to file.
+    md5: str | None
+        Expected MD5 checksum of file. If None, do not check. (default: None)
+
+    Returns
+    -------
+    bool
+        True if file checksum matches passed `md5` or if passed `md5` is None. False if file
+        checksum does not match passed `md5` or `filepath` doesn't exist.
+    """
+    if not filepath.is_file():
+        return False
+    if md5 is None:
+        return True
+
+    # Calculate checksum and check for match.
+    file_md5 = _calculate_md5(filepath)
+    return file_md5 == md5
+
+
+def _calculate_md5(filepath: Path, chunk_size: int = 1024 * 1024) -> str:
+    """Calculate MD5 checksum.
+
+    Parameters
+    ----------
+    filepath : Path
+        Path to file.
+    chunk_size : int
+        Byte size of processed chunks. (default: 1024 * 1024)
+
+    Returns
+    -------
+    str
+        Calculated MD5 checksum.
+    """
+    # Setting the `usedforsecurity` flag does not change anything about the functionality, but
+    # indicates that we are not using the MD5 checksum for cryptography.
+    # This enables its usage in restricted environments like FIPS without raising an error.
+    file_md5 = hashlib.new('md5', usedforsecurity=False)
+
+    with open(filepath, 'rb') as f:
+        for chunk in iter(lambda: f.read(chunk_size), b''):
+            file_md5.update(chunk)
+    return file_md5.hexdigest()
diff --git a/src/pymovements/dataset/dataset_download.py b/src/pymovements/dataset/dataset_download.py
index 8a647f4..4ff1c40 100644
--- a/src/pymovements/dataset/dataset_download.py
+++ b/src/pymovements/dataset/dataset_download.py
@@ -26,9 +26,9 @@ from urllib.error import URLError
 from warnings import warn
 
 from pymovements.dataset._utils._archives import extract_archive
+from pymovements.dataset._utils._downloads import download_file
 from pymovements.dataset.dataset_definition import DatasetDefinition
 from pymovements.dataset.dataset_paths import DatasetPaths
-from pymovements.utils.downloads import download_file
 
 
 def download_dataset(
diff --git a/src/pymovements/utils/downloads.py b/src/pymovements/utils/downloads.py
index 1d93c69..19710c2 100644
--- a/src/pymovements/utils/downloads.py
+++ b/src/pymovements/utils/downloads.py
@@ -17,21 +17,28 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-"""Utils module for downloading files."""
+"""Utils module for downloading files.
+
+.. deprecated:: v0.21.1
+   Please use :py:meth:`~pymovements.Dataset.download()` instead.
+   This module will be removed in v0.26.0.
+"""
 from __future__ import annotations
 
-import hashlib
-import urllib.request
 from pathlib import Path
-from typing import Any
-
-from tqdm.auto import tqdm
 
-from pymovements.dataset._utils._archives import extract_archive
+from deprecated.sphinx import deprecated
 
-USER_AGENT: str = 'pymovements'
+from pymovements.dataset._utils._downloads import download_and_extract_archive \
+    as _download_and_extract_archive
+from pymovements.dataset._utils._downloads import download_file as _download_file
 
 
+@deprecated(
+    reason='Please use Dataset.download() instead. '
+           'This function will be removed in v0.26.0.',
+    version='v0.21.1',
+)
 def download_and_extract_archive(
         url: str,
         download_dirpath: Path,
@@ -45,6 +52,10 @@ def download_and_extract_archive(
 ) -> None:
     """Download and extract archive file.
 
+    .. deprecated:: v0.21.1
+       Please use :py:meth:`~pymovements.Dataset.download()` instead.
+       This function will be removed in v0.26.0.
+
     Parameters
     ----------
     url: str
@@ -74,20 +85,12 @@ def download_and_extract_archive(
         If the downloaded file has no suffix or suffix is not supported, or in case of a
         specified MD5 checksum which doesn't match the checksum of the downloaded file.
     """
-    archive_path = download_file(
+    return _download_and_extract_archive(
         url=url,
-        dirpath=download_dirpath,
-        filename=download_filename,
+        download_dirpath=download_dirpath,
+        download_filename=download_filename,
+        extract_dirpath=extract_dirpath,
         md5=md5,
-        verbose=bool(verbose),
-    )
-
-    if extract_dirpath is None:
-        extract_dirpath = download_dirpath
-
-    extract_archive(
-        source_path=archive_path,
-        destination_path=extract_dirpath,
         recursive=recursive,
         remove_finished=remove_finished,
         remove_top_level=remove_top_level,
@@ -95,6 +98,11 @@ def download_and_extract_archive(
     )
 
 
+@deprecated(
+    reason='Please use Dataset.download() instead. '
+           'This function will be removed in v0.26.0.',
+    version='v0.21.1',
+)
 def download_file(
         url: str,
         dirpath: Path,
@@ -105,6 +113,10 @@ def download_file(
 ) -> Path:
     """Download a file from a URL and place it in root.
 
+    .. deprecated:: v0.21.1
+       Please use :py:meth:`~pymovements.Dataset.download()` instead.
+       This function will be removed in v0.26.0.
+
     Parameters
     ----------
     url : str
@@ -132,188 +144,11 @@ def download_file(
     RuntimeError
         If the MD5 checksum of the downloaded file did not match the expected checksum.
     """
-    dirpath = dirpath.expanduser()
-    dirpath.mkdir(parents=True, exist_ok=True)
-    filepath = dirpath / filename
-
-    # check if file is already present locally
-    if _check_integrity(filepath, md5):
-        if verbose:
-            print('Using already downloaded and verified file:', filepath)
-        return filepath
-
-    if verbose:
-        print(f'Downloading {url} to {filepath}')
-
-    # expand redirect chain if needed
-    url = _get_redirected_url(url=url, max_hops=max_redirect_hops)
-
-    # download the file
-    try:
-        _download_url(url=url, destination=filepath, verbose=verbose)
-
-    except OSError as e:
-        if url[:5] == 'https':
-            print('Download failed. Trying https -> http instead.')
-            url = url.replace('https:', 'http:')
-
-            if verbose:
-                print(f'Downloading {url} to {filepath}')
-            _download_url(url=url, destination=filepath, verbose=verbose)
-        else:
-            raise e
-
-    # check integrity of downloaded file
-    if verbose:
-        print(f'Checking integrity of {filepath.name}')
-    if not _check_integrity(filepath=filepath, md5=md5):
-        raise RuntimeError(f'File {filepath} not found or download corrupted.')
-
-    return filepath
-
-
-def _get_redirected_url(url: str, max_hops: int = 3) -> str:
-    """Get redirected URL.
-
-    Parameters
-    ----------
-    url: str
-        Initial URL to be requested for redirection.
-    max_hops: int
-        Maximum number of redirection hops. (default: 3)
-
-    Returns
-    -------
-    str
-        Final URL after all redirections.
-
-    Raises
-    ------
-    RuntimeError
-        If number of redirects exceed `max_hops`.
-    """
-    initial_url = url
-    headers = {'Method': 'HEAD', 'User-Agent': USER_AGENT}
-    opener = urllib.request.build_opener()
-    opener.addheaders = [('User-Agent', USER_AGENT)]
-    urllib.request.install_opener(opener)
-
-    for _ in range(max_hops + 1):
-        with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as response:
-            if response.url == url or response.url is None:
-                return url
-            url = response.url
-
-    raise RuntimeError(
-        f'Request to {initial_url} exceeded {max_hops} redirects.'
-        f' The last redirect points to {url}.',
+    return _download_file(
+        url=url,
+        dirpath=dirpath,
+        filename=filename,
+        md5=md5,
+        max_redirect_hops=max_redirect_hops,
+        verbose=verbose,
     )
-
-
-# have to ignore pylint since tqdm 2.45.0 broke incosistent-mro
-# https://github.com/tqdm/tqdm/blob/0bb91857eca0d4aea08f66cf1c8949abe0cd6b7a/tqdm/auto.py#L27
-class _DownloadProgressBar(tqdm):  # pylint: disable=inconsistent-mro
-    """Progress bar for downloads.
-
-    Provides `update_to(n)` which uses `tqdm.update(delta_n)`.
-
-    Reference: https://github.com/tqdm/tqdm#hooks-and-callbacks
-
-    Parameters
-    ----------
-    **kwargs : Any
-    """
-
-    def __init__(self, **kwargs: Any):
-        super().__init__(unit='B', unit_scale=True, unit_divisor=1024, miniters=1, **kwargs)
-
-    def update_to(self, b: int = 1, bsize: int = 1, tsize: int | None = None) -> bool | None:
-        """Update progress bar.
-
-        Parameters
-        ----------
-        b  : int
-            Number of blocks transferred so far (default: 1).
-        bsize  : int
-            Size of each block (in tqdm units) (default: 1).
-        tsize  : int | None
-            Total size (in tqdm units). If None it remains unchanged (default: None).
-
-        Returns
-        -------
-        bool | None
-            Returns `None` if the update was successful, `False` if the update was skipped
-            because the last update was too recent.
-        """
-        if tsize is not None:
-            self.total = tsize
-        return self.update(b * bsize - self.n)  # also sets self.n = b * bsize
-
-
-def _download_url(url: str, destination: Path, verbose: bool = True) -> None:
-    """Download file from URL and save to destination.
-
-    Parameters
-    ----------
-    url : str
-        URL of file to be downloaded.
-    destination : Path
-        Destination path of downloaded file.
-    verbose : bool
-        If True, show progressbar.
-    """
-    with _DownloadProgressBar(desc=destination.name, disable=not verbose) as t:
-        urllib.request.urlretrieve(url=url, filename=destination, reporthook=t.update_to)
-        t.total = t.n
-
-
-def _check_integrity(filepath: Path, md5: str | None = None) -> bool:
-    """Check file integrity by MD5 checksum.
-
-    Parameters
-    ----------
-    filepath : Path
-        Path to file.
-    md5: str | None
-        Expected MD5 checksum of file. If None, do not check. (default: None)
-
-    Returns
-    -------
-    bool
-        True if file checksum matches passed `md5` or if passed `md5` is None. False if file
-        checksum does not match passed `md5` or `filepath` doesn't exist.
-    """
-    if not filepath.is_file():
-        return False
-    if md5 is None:
-        return True
-
-    # Calculate checksum and check for match.
-    file_md5 = _calculate_md5(filepath)
-    return file_md5 == md5
-
-
-def _calculate_md5(filepath: Path, chunk_size: int = 1024 * 1024) -> str:
-    """Calculate MD5 checksum.
-
-    Parameters
-    ----------
-    filepath : Path
-        Path to file.
-    chunk_size : int
-        Byte size of processed chunks. (default: 1024 * 1024)
-
-    Returns
-    -------
-    str
-        Calculated MD5 checksum.
-    """
-    # Setting the `usedforsecurity` flag does not change anything about the functionality, but
-    # indicates that we are not using the MD5 checksum for cryptography.
-    # This enables its usage in restricted environments like FIPS without raising an error.
-    file_md5 = hashlib.new('md5', usedforsecurity=False)
-
-    with open(filepath, 'rb') as f:
-        for chunk in iter(lambda: f.read(chunk_size), b''):
-            file_md5.update(chunk)
-    return file_md5.hexdigest()


Test patch:
diff --git a/tests/unit/dataset/_utils/_downloads_test.py b/tests/unit/dataset/_utils/_downloads_test.py
new file mode 100644
index 0000000..9a5187f
--- /dev/null
+++ b/tests/unit/dataset/_utils/_downloads_test.py
@@ -0,0 +1,244 @@
+# Copyright (c) 2023-2025 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Test pymovements utils downloads."""
+import hashlib
+import os.path
+from unittest import mock
+
+import pytest
+
+from pymovements.dataset._utils._downloads import _DownloadProgressBar
+from pymovements.dataset._utils._downloads import _get_redirected_url
+from pymovements.dataset._utils._downloads import download_and_extract_archive
+from pymovements.dataset._utils._downloads import download_file
+
+
+@pytest.mark.parametrize(
+    'verbose',
+    [
+        pytest.param(False, id='verbose_false'),
+        pytest.param(True, id='verbose_true'),
+    ],
+)
+def test_download_file(tmp_path, verbose):
+    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
+    filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
+
+    filepath = download_file(url, tmp_path, filename, md5, verbose=verbose)
+
+    assert filepath.exists()
+    assert filepath.name == filename
+    assert filepath.parent == tmp_path
+
+    with open(filepath, 'rb') as f:
+        file_bytes = f.read()
+        assert hashlib.md5(file_bytes).hexdigest() == md5
+
+
+def test_download_file_md5_None(tmp_path):
+    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
+    filename = 'pymovements-0.4.0.tar.gz'
+
+    filepath = download_file(url, tmp_path, filename)
+
+    assert filepath.exists()
+    assert filepath.name == filename
+    assert filepath.parent == tmp_path
+
+
+def test_download_file_404(tmp_path):
+    url = 'http://github.com/aeye-lab/pymovement/archive/refs/tags/v0.4.0.tar.gz'
+    filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
+
+    with pytest.raises(OSError):
+        download_file(url, tmp_path, filename, md5)
+
+
+@pytest.mark.parametrize(
+    'verbose',
+    [
+        pytest.param(False, id='verbose_false'),
+        pytest.param(True, id='verbose_true'),
+    ],
+)
+def test_download_file_https_failure(tmp_path, verbose):
+    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
+    filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
+
+    with mock.patch(
+        'pymovements.dataset._utils._downloads._download_url',
+        side_effect=OSError(),
+    ):
+        with pytest.raises(OSError):
+            download_file(url, tmp_path, filename, md5, verbose=verbose)
+
+
+def test_download_file_http_failure(tmp_path):
+    url = 'http://example.com/'
+    filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
+
+    with mock.patch(
+        'pymovements.dataset._utils._downloads._download_url',
+        side_effect=OSError(),
+    ):
+        with pytest.raises(OSError):
+            download_file(url, tmp_path, filename, md5)
+
+
+def test_download_file_with_invalid_md5(tmp_path):
+    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
+    filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '00000000000000000000000000000000'
+
+    with pytest.raises(RuntimeError) as excinfo:
+        download_file(url, tmp_path, filename, md5)
+
+    msg, = excinfo.value.args
+    assert msg == f"File {os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')} "\
+        'not found or download corrupted.'
+
+
+def test__get_redirected_url():
+    url = 'https://codeload.github.com/aeye-lab/pymovements/tar.gz/refs/tags/v0.4.0'
+    expected_final_url = 'https://codeload.github.com/aeye-lab/pymovements/tar.gz/refs/tags/v0.4.0'
+
+    final_url = _get_redirected_url(url)
+
+    assert final_url == expected_final_url
+
+
+def test__get_redirected_url_with_redirects():
+    url = 'https://github.com/aeye-lab/pymovements/archive/master.zip'
+    expected_final_url = 'https://codeload.github.com/aeye-lab/pymovements/zip/main'
+
+    final_url = _get_redirected_url(url)
+
+    assert final_url == expected_final_url
+
+
+def test__get_redirected_url_with_redirects_max_hops():
+    url = 'https://github.com/aeye-lab/pymovements/archive/master.zip'
+
+    with pytest.raises(RuntimeError) as excinfo:
+        _get_redirected_url(url, max_hops=0)
+
+    msg, = excinfo.value.args
+    assert msg == 'Request to '\
+        'https://github.com/aeye-lab/pymovements/archive/master.zip '\
+        'exceeded 0 redirects. The last redirect points to '\
+        'https://codeload.github.com/aeye-lab/pymovements/zip/main.'
+
+
+def test_download_and_extract_archive(tmp_path):
+    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
+    download_filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
+    extract_dirpath = tmp_path / 'extracted'
+
+    download_and_extract_archive(
+        url,
+        tmp_path,
+        download_filename,
+        extract_dirpath,
+        md5,
+        remove_top_level=False,
+    )
+
+    assert extract_dirpath.exists()
+    assert (extract_dirpath / 'pymovements-0.4.0').exists()
+    assert (extract_dirpath / 'pymovements-0.4.0' / 'README.md').exists()
+
+    archive_path = tmp_path / download_filename
+    assert archive_path.exists()
+    with open(archive_path, 'rb') as f:
+        file_bytes = f.read()
+        assert hashlib.md5(file_bytes).hexdigest() == md5
+
+
+@pytest.mark.parametrize(
+    'verbose',
+    [
+        pytest.param(False, id='verbose_false'),
+        pytest.param(True, id='verbose_true'),
+    ],
+)
+def test_download_and_extract_archive_extract_dirpath_None(tmp_path, capsys, verbose):
+    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
+    download_filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
+    extract_dirpath = None
+
+    # extract first time
+    download_and_extract_archive(
+        url, tmp_path, download_filename, extract_dirpath, md5, verbose=verbose,
+    )
+    out, _ = capsys.readouterr()
+
+    if verbose:
+        assert out == 'Downloading '\
+            'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'\
+            f" to {os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')}\n" \
+            f'Checking integrity of pymovements-0.4.0.tar.gz\n'\
+            f'Extracting pymovements-0.4.0.tar.gz to {tmp_path}\n'
+    else:
+        assert out == ''
+
+    # extract second time to test already downloaded and verified file
+    download_and_extract_archive(
+        url, tmp_path, download_filename, extract_dirpath, md5, verbose=verbose,
+    )
+    out, _ = capsys.readouterr()
+
+    if verbose:
+        assert out == f'Using already downloaded and verified file: '\
+            f"{os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')}"\
+            f'\nExtracting pymovements-0.4.0.tar.gz to {tmp_path}\n'
+    else:
+        assert out == ''
+
+
+def test_download_and_extract_archive_invalid_md5(tmp_path):
+    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
+    download_filename = 'pymovements-0.4.0.tar.gz'
+    md5 = '00000000000000000000000000000000'
+    extract_dirpath = tmp_path / 'extracted'
+
+    with pytest.raises(RuntimeError) as excinfo:
+        download_and_extract_archive(url, tmp_path, download_filename, extract_dirpath, md5)
+
+    msg, = excinfo.value.args
+    assert msg == f"File {os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')} "\
+        'not found or download corrupted.'
+
+
+def test__DownloadProgressBar_tsize_not_None():
+    download_progress_bar = _DownloadProgressBar()
+    assert download_progress_bar.n == 0
+    assert download_progress_bar.total is None
+    download_progress_bar.update_to()
+    assert download_progress_bar.n == 1
+    assert download_progress_bar.total is None
+    download_progress_bar.update_to(tsize=100)
+    assert download_progress_bar.n == 1
+    assert download_progress_bar.total == 100
diff --git a/tests/unit/utils/downloads_test.py b/tests/unit/utils/downloads_test.py
index c1bbed8..447c0e8 100644
--- a/tests/unit/utils/downloads_test.py
+++ b/tests/unit/utils/downloads_test.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2025 The pymovements Project Authors
+# Copyright (c) 2025 The pymovements Project Authors
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to deal
@@ -17,228 +17,48 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-"""Test pymovements utils downloads."""
-import hashlib
-import os.path
-from unittest import mock
+"""Tests deprecated utils.downloads."""
+import re
 
 import pytest
 
-from pymovements.utils.downloads import _DownloadProgressBar
-from pymovements.utils.downloads import _get_redirected_url
+from pymovements import __version__
 from pymovements.utils.downloads import download_and_extract_archive
 from pymovements.utils.downloads import download_file
 
 
-@pytest.mark.parametrize(
-    'verbose',
-    [
-        pytest.param(False, id='verbose_false'),
-        pytest.param(True, id='verbose_true'),
-    ],
-)
-def test_download_file(tmp_path, verbose):
+@pytest.mark.filterwarnings('ignore::DeprecationWarning')
+@pytest.mark.parametrize('download_function', [download_and_extract_archive, download_file])
+def test_download_file(download_function, tmp_path):
     url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
     filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
 
-    filepath = download_file(url, tmp_path, filename, md5, verbose=verbose)
+    download_function(url, tmp_path, filename)
 
-    assert filepath.exists()
-    assert filepath.name == filename
-    assert filepath.parent == tmp_path
 
-    with open(filepath, 'rb') as f:
-        file_bytes = f.read()
-        assert hashlib.md5(file_bytes).hexdigest() == md5
-
-
-def test_download_file_md5_None(tmp_path):
+@pytest.mark.parametrize('download_function', [download_and_extract_archive, download_file])
+def test_archive_extract_deprecated(download_function, tmp_path):
     url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
     filename = 'pymovements-0.4.0.tar.gz'
 
-    filepath = download_file(url, tmp_path, filename)
-
-    assert filepath.exists()
-    assert filepath.name == filename
-    assert filepath.parent == tmp_path
-
-
-def test_download_file_404(tmp_path):
-    url = 'http://github.com/aeye-lab/pymovement/archive/refs/tags/v0.4.0.tar.gz'
-    filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
-
-    with pytest.raises(OSError):
-        download_file(url, tmp_path, filename, md5)
+    with pytest.raises(DeprecationWarning):
+        download_function(url, tmp_path, filename)
 
 
-@pytest.mark.parametrize(
-    'verbose',
-    [
-        pytest.param(False, id='verbose_false'),
-        pytest.param(True, id='verbose_true'),
-    ],
-)
-def test_download_file_https_failure(tmp_path, verbose):
+@pytest.mark.parametrize('download_function', [download_and_extract_archive, download_file])
+def test_archive_extract_removed(download_function, tmp_path):
     url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
     filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
-
-    with mock.patch(
-        'pymovements.utils.downloads._download_url',
-        side_effect=OSError(),
-    ):
-        with pytest.raises(OSError):
-            download_file(url, tmp_path, filename, md5, verbose=verbose)
-
-
-def test_download_file_http_failure(tmp_path):
-    url = 'http://example.com/'
-    filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
-
-    with mock.patch(
-        'pymovements.utils.downloads._download_url',
-        side_effect=OSError(),
-    ):
-        with pytest.raises(OSError):
-            download_file(url, tmp_path, filename, md5)
-
-
-def test_download_file_with_invalid_md5(tmp_path):
-    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
-    filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '00000000000000000000000000000000'
-
-    with pytest.raises(RuntimeError) as excinfo:
-        download_file(url, tmp_path, filename, md5)
-
-    msg, = excinfo.value.args
-    assert msg == f"File {os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')} "\
-        'not found or download corrupted.'
-
-
-def test__get_redirected_url():
-    url = 'https://codeload.github.com/aeye-lab/pymovements/tar.gz/refs/tags/v0.4.0'
-    expected_final_url = 'https://codeload.github.com/aeye-lab/pymovements/tar.gz/refs/tags/v0.4.0'
-
-    final_url = _get_redirected_url(url)
-
-    assert final_url == expected_final_url
-
-
-def test__get_redirected_url_with_redirects():
-    url = 'https://github.com/aeye-lab/pymovements/archive/master.zip'
-    expected_final_url = 'https://codeload.github.com/aeye-lab/pymovements/zip/main'
-
-    final_url = _get_redirected_url(url)
 
-    assert final_url == expected_final_url
+    with pytest.raises(DeprecationWarning) as info:
+        download_function(url, tmp_path, filename)
 
+    regex = re.compile(r'.*will be removed in v(?P<version>[0-9]*[.][0-9]*[.][0-9]*)[.)].*')
 
-def test__get_redirected_url_with_redirects_max_hops():
-    url = 'https://github.com/aeye-lab/pymovements/archive/master.zip'
-
-    with pytest.raises(RuntimeError) as excinfo:
-        _get_redirected_url(url, max_hops=0)
-
-    msg, = excinfo.value.args
-    assert msg == 'Request to '\
-        'https://github.com/aeye-lab/pymovements/archive/master.zip '\
-        'exceeded 0 redirects. The last redirect points to '\
-        'https://codeload.github.com/aeye-lab/pymovements/zip/main.'
-
-
-def test_download_and_extract_archive(tmp_path):
-    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
-    download_filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
-    extract_dirpath = tmp_path / 'extracted'
-
-    download_and_extract_archive(
-        url,
-        tmp_path,
-        download_filename,
-        extract_dirpath,
-        md5,
-        remove_top_level=False,
-    )
-
-    assert extract_dirpath.exists()
-    assert (extract_dirpath / 'pymovements-0.4.0').exists()
-    assert (extract_dirpath / 'pymovements-0.4.0' / 'README.md').exists()
-
-    archive_path = tmp_path / download_filename
-    assert archive_path.exists()
-    with open(archive_path, 'rb') as f:
-        file_bytes = f.read()
-        assert hashlib.md5(file_bytes).hexdigest() == md5
-
-
-@pytest.mark.parametrize(
-    'verbose',
-    [
-        pytest.param(False, id='verbose_false'),
-        pytest.param(True, id='verbose_true'),
-    ],
-)
-def test_download_and_extract_archive_extract_dirpath_None(tmp_path, capsys, verbose):
-    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
-    download_filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '52bbf03a7c50ee7152ccb9d357c2bb30'
-    extract_dirpath = None
-
-    # extract first time
-    download_and_extract_archive(
-        url, tmp_path, download_filename, extract_dirpath, md5, verbose=verbose,
-    )
-    out, _ = capsys.readouterr()
-
-    if verbose:
-        assert out == 'Downloading '\
-            'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'\
-            f" to {os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')}\n" \
-            f'Checking integrity of pymovements-0.4.0.tar.gz\n'\
-            f'Extracting pymovements-0.4.0.tar.gz to {tmp_path}\n'
-    else:
-        assert out == ''
-
-    # extract second time to test already downloaded and verified file
-    download_and_extract_archive(
-        url, tmp_path, download_filename, extract_dirpath, md5, verbose=verbose,
+    msg = info.value.args[0]
+    remove_version = regex.match(msg).groupdict()['version']
+    current_version = __version__.split('+')[0]
+    assert current_version < remove_version, (
+        f'utils/downloads.py was planned to be removed in v{remove_version}. '
+        f'Current version is v{current_version}.'
     )
-    out, _ = capsys.readouterr()
-
-    if verbose:
-        assert out == f'Using already downloaded and verified file: '\
-            f"{os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')}"\
-            f'\nExtracting pymovements-0.4.0.tar.gz to {tmp_path}\n'
-    else:
-        assert out == ''
-
-
-def test_download_and_extract_archive_invalid_md5(tmp_path):
-    url = 'https://github.com/aeye-lab/pymovements/archive/refs/tags/v0.4.0.tar.gz'
-    download_filename = 'pymovements-0.4.0.tar.gz'
-    md5 = '00000000000000000000000000000000'
-    extract_dirpath = tmp_path / 'extracted'
-
-    with pytest.raises(RuntimeError) as excinfo:
-        download_and_extract_archive(url, tmp_path, download_filename, extract_dirpath, md5)
-
-    msg, = excinfo.value.args
-    assert msg == f"File {os.path.join(tmp_path, 'pymovements-0.4.0.tar.gz')} "\
-        'not found or download corrupted.'
-
-
-def test__DownloadProgressBar_tsize_not_None():
-    download_progress_bar = _DownloadProgressBar()
-    assert download_progress_bar.n == 0
-    assert download_progress_bar.total is None
-    download_progress_bar.update_to()
-    assert download_progress_bar.n == 1
-    assert download_progress_bar.total is None
-    download_progress_bar.update_to(tsize=100)
-    assert download_progress_bar.n == 1
-    assert download_progress_bar.total == 100