Repository: aeye-lab/pymovements
Pull request #1089 (2025-04-12 00:27:44)

feat: automatically infer if resources need extract
## Description

This PR automatically infers if dataset resources need to be extracted or decompressed.
It removes the need to define `DatasetDefinition.extract`.

`DatasetDefinition.extract` is not removed yet. It will be deprecated in a follow-up PR #1159 

## Implemented changes

- [x] autodetect of resources need extraction by checking filename suffixes
- [x] added `UnknownFileType` exception
- [x] refactored `download_dataset()` and `extract_dataset()`
- [x] improved some error handling

## How Has This Been Tested?

Testing could certainly improved but this would probably need a rewrite of `dataset_download_test.py`.

This will happen in some of the upcoming PRs where I will create a `Resources.extract()` method which will be more sensible to test than the private methods currently tested in `dataset_download_test.py`.

From the outside the behavior stays the same (if `extract`was specified correctly), so the tests are mostly left as they were.

## Context

#### related issues:
- #1120

#### requires:
- [x] #1081 
- [x] #1094 

#### required by:
- [ ] #1158
- [ ] #1159 

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Attention: Patch coverage is `24.32432%` with `56 lines` in your changes missing coverage. Please review.
> Project coverage is 38.30%. Comparing base [(`362c4f8`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/362c4f854b05d75ae8142b8e0ded65d6e898595f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`17b703e`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/17b703e60197e204e3aa5a98d071195ae4d214f1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).
> Report is 7 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Patch % | Lines |
|---|---|---|
| [src/pymovements/dataset/\_downloads.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?src=pr&el=tree&filepath=src%2Fpymovements%2Fdataset%2F_downloads.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvX2Rvd25sb2Fkcy5weQ==) | 21.73% | [36 Missing :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |
| [src/pymovements/dataset/\_archives.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?src=pr&el=tree&filepath=src%2Fpymovements%2Fdataset%2F_archives.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvX2FyY2hpdmVzLnB5) | 18.18% | [18 Missing :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |
| [src/pymovements/dataset/dataset.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?src=pr&el=tree&filepath=src%2Fpymovements%2Fdataset%2Fdataset.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldC5weQ==) | 50.00% | [2 Missing :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |

> :exclamation:  There is a different number of reports uploaded between BASE (362c4f8) and HEAD (17b703e). Click for more details.
> 
> <details><summary>HEAD has 11 uploads less than BASE</summary>
>
>| Flag | BASE (362c4f8) | HEAD (17b703e) |
>|------|------|------|
>||15|4|
></details>

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


```diff
@@             Coverage Diff              @@
##              main    #1089       +/-   ##
============================================
- Coverage   100.00%   38.30%   -61.70%     
============================================
  Files           90       83        -7     
  Lines         3985     3924       -61     
  Branches       708      704        -4     
============================================
- Hits          3985     1503     -2482     
- Misses           0     2411     +2411     
- Partials         0       10       +10     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1089?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/src/pymovements/dataset/_utils/_archives.py b/src/pymovements/dataset/_utils/_archives.py
index 9e970ba..f7eaeed 100644
--- a/src/pymovements/dataset/_utils/_archives.py
+++ b/src/pymovements/dataset/_utils/_archives.py
@@ -35,6 +35,7 @@ from typing import IO
 from tqdm import tqdm
 
 from pymovements._utils._paths import get_filepaths
+from pymovements.exceptions import UnknownFileType
 
 
 def extract_archive(
@@ -269,11 +270,11 @@ def _detect_file_type(filepath: Path) -> tuple[str | None, str | None]:
 
     Raises
     ------
-    RuntimeError
+    UnknownFileType
         If the file has no suffix or the suffix is not supported.
     """
     if not (suffixes := filepath.suffixes):
-        raise RuntimeError(
+        raise UnknownFileType(
             f"File '{filepath}' has no suffixes that could be used to detect the archive type or"
             ' compression.',
         )
@@ -296,9 +297,9 @@ def _detect_file_type(filepath: Path) -> tuple[str | None, str | None]:
 
             # Check if the second last suffix refers to an archive type.
             if (suffix2 := suffixes[-2]) not in _ARCHIVE_EXTRACTORS:
-                raise RuntimeError(
-                    f"Unsupported archive type: '{suffix2}'.\n"
-                    f"Supported suffixes are: '{sorted(set(_ARCHIVE_EXTRACTORS))}'.",
+                raise UnknownFileType(
+                    f"Unsupported compression or archive type: '{suffix2}{suffix}'.\n"
+                    f"Supported suffixes are: '{_get_valid_suffixes()}'.",
                 )
             # We detected a compressed archive file (e.g. tar.gz).
             return suffix2, suffix
@@ -307,12 +308,9 @@ def _detect_file_type(filepath: Path) -> tuple[str | None, str | None]:
         return None, suffix
 
     # Raise error as we didn't find a valid suffix.
-    valid_suffixes = sorted(
-        set(_ARCHIVE_TYPE_ALIASES) | set(_ARCHIVE_EXTRACTORS) | set(_COMPRESSED_FILE_OPENERS),
-    )
-    raise RuntimeError(
+    raise UnknownFileType(
         f"Unsupported compression or archive type: '{suffix}'.\n"
-        f"Supported suffixes are: '{valid_suffixes}'.",
+        f"Supported suffixes are: '{_get_valid_suffixes()}'.",
     )
 
 
@@ -362,3 +360,10 @@ def _decompress(
         source_path.unlink()
 
     return destination_path
+
+
+def _get_valid_suffixes() -> list[str]:
+    """Get valid archive file extensions."""
+    return sorted(
+        set(_ARCHIVE_TYPE_ALIASES) | set(_ARCHIVE_EXTRACTORS) | set(_COMPRESSED_FILE_OPENERS),
+    )
diff --git a/src/pymovements/dataset/_utils/_downloads.py b/src/pymovements/dataset/_utils/_downloads.py
index 1bc777e..ec02c59 100644
--- a/src/pymovements/dataset/_utils/_downloads.py
+++ b/src/pymovements/dataset/_utils/_downloads.py
@@ -28,80 +28,10 @@ from typing import Any
 from tqdm.auto import tqdm
 
 from pymovements._version import get_versions
-from pymovements.dataset._utils._archives import extract_archive
 
 USER_AGENT: str = f"pymovements/{get_versions()['version']}"
 
 
-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,
-        resume: 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)
-    resume: bool
-        Resume previous extraction by skipping existing files.
-        Checks for correct size of existing files but not integrity. (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,
-        resume=resume,
-        verbose=verbose,
-    )
-
-
 def download_file(
         url: str,
         dirpath: Path,
diff --git a/src/pymovements/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index 25301be..389fb79 100644
--- a/src/pymovements/dataset/dataset.py
+++ b/src/pymovements/dataset/dataset.py
@@ -159,7 +159,7 @@ class Dataset:
         if self.definition.has_files['precomputed_reading_measures']:
             self.load_precomputed_reading_measures()
 
-        # Events extracted previously by pymovements
+        # Events detected previously by pymovements
         if events:
             self.load_event_files(
                 events_dirname=events_dirname,
diff --git a/src/pymovements/dataset/dataset_download.py b/src/pymovements/dataset/dataset_download.py
index e80696d..6c190f6 100644
--- a/src/pymovements/dataset/dataset_download.py
+++ b/src/pymovements/dataset/dataset_download.py
@@ -29,6 +29,7 @@ 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.exceptions import UnknownFileType
 
 
 def download_dataset(
@@ -77,58 +78,28 @@ def download_dataset(
     RuntimeError
         If downloading a resource failed for all given mirrors.
     """
-    if definition.has_files['gaze']:
-        if not definition.mirrors or not definition.mirrors['gaze']:
-            mirrors = None
-        else:
-            mirrors = definition.mirrors['gaze']
-
-        if not definition.resources or not definition.resources['gaze']:
-            raise AttributeError("'gaze' resources must be specified to download dataset.")
-
-        _download_resources(
-            mirrors=mirrors,
-            resources=definition.resources['gaze'],
-            target_dirpath=paths.downloads,
-            verbose=verbose,
-        )
-
-    if definition.has_files['precomputed_events']:
-        if not definition.mirrors or not definition.mirrors['precomputed_events']:
-            mirrors = None
-        else:
-            mirrors = definition.mirrors['precomputed_events']
+    if not definition.resources:
+        raise AttributeError('resources must be specified to download dataset.')
 
-        if not definition.resources or not definition.resources['precomputed_events']:
-            raise AttributeError(
-                "'precomputed_events' resources must be specified to download dataset.",
-            )
-
-        _download_resources(
-            mirrors=mirrors,
-            resources=definition.resources['precomputed_events'],
-            target_dirpath=paths.downloads,
-            verbose=verbose,
-        )
+    for content in ('gaze', 'precomputed_events', 'precomputed_reading_measures'):
+        if definition.has_files[content]:
+            if not definition.mirrors:
+                mirrors = None
+            else:
+                mirrors = definition.mirrors.get(content, None)
 
-    if definition.has_files['precomputed_reading_measures']:
-        if not definition.mirrors or not definition.mirrors['precomputed_reading_measures']:
-            mirrors = None
-        else:
-            mirrors = definition.mirrors['precomputed_reading_measures']
+            if not definition.resources[content]:
+                raise AttributeError(
+                    f"'{content}' resources must be specified to download dataset.",
+                )
 
-        if not definition.resources or not definition.resources['precomputed_reading_measures']:
-            raise AttributeError(
-                "'precomputed_reading_measures' resources must be specified to download dataset.",
+            _download_resources(
+                mirrors=mirrors,
+                resources=definition.resources[content],
+                target_dirpath=paths.downloads,
+                verbose=verbose,
             )
 
-        _download_resources(
-            mirrors=mirrors,
-            resources=definition.resources['precomputed_reading_measures'],
-            target_dirpath=paths.downloads,
-            verbose=verbose,
-        )
-
     if extract:
         extract_dataset(
             definition=definition,
@@ -168,59 +139,31 @@ def extract_dataset(
         messages for recursive archives. (2) Print messages for extracting each dataset resource and
         each recursive archive extract. (default: 1)
     """
-    if definition.has_files['gaze'] and definition.extract['gaze']:
-        paths.raw.mkdir(parents=True, exist_ok=True)
-        for resource in definition.resources['gaze']:
-            source_path = paths.downloads / resource['filename']
-            destination_path = paths.raw
-
-            extract_archive(
-                source_path=source_path,
-                destination_path=destination_path,
-                recursive=True,
-                remove_finished=remove_finished,
-                remove_top_level=remove_top_level,
-                resume=resume,
-                verbose=verbose,
-            )
-
-    if definition.has_files['precomputed_events']:
-        paths.precomputed_events.mkdir(parents=True, exist_ok=True)
-        for resource in definition.resources['precomputed_events']:
-            source_path = paths.downloads / resource['filename']
-            destination_path = paths.precomputed_events
-
-            if definition.extract['precomputed_events']:
-                extract_archive(
-                    source_path=source_path,
-                    destination_path=destination_path,
-                    recursive=True,
-                    remove_finished=remove_finished,
-                    remove_top_level=remove_top_level,
-                    resume=resume,
-                    verbose=verbose,
-                )
-            else:
-                shutil.copy(source_path, destination_path / resource['filename'])
-
-    if definition.has_files['precomputed_reading_measures']:
-        paths.precomputed_reading_measures.mkdir(parents=True, exist_ok=True)
-        for resource in definition.resources['precomputed_reading_measures']:
-            source_path = paths.downloads / resource['filename']
-            destination_path = paths.precomputed_reading_measures
-
-            if definition.extract['precomputed_reading_measures']:
-                extract_archive(
-                    source_path=source_path,
-                    destination_path=destination_path,
-                    recursive=True,
-                    remove_finished=remove_finished,
-                    remove_top_level=remove_top_level,
-                    resume=resume,
-                    verbose=verbose,
-                )
-            else:
-                shutil.move(source_path, destination_path / resource['filename'])
+    content_dirnames = {
+        'gaze': 'raw',
+        'precomputed_events': 'precomputed_events',
+        'precomputed_reading_measures': 'precomputed_reading_measures',
+    }
+
+    for content, content_directory in content_dirnames.items():
+        if definition.has_files[content]:
+            destination_dirpath = getattr(paths, content_directory)
+            destination_dirpath.mkdir(parents=True, exist_ok=True)
+            for resource in definition.resources[content]:
+                source_path = paths.downloads / resource['filename']
+
+                try:
+                    extract_archive(
+                        source_path=source_path,
+                        destination_path=destination_dirpath,
+                        recursive=True,
+                        remove_finished=remove_finished,
+                        remove_top_level=remove_top_level,
+                        resume=resume,
+                        verbose=verbose,
+                    )
+                except UnknownFileType:  # just copy file to target if not an archive.
+                    shutil.copy(source_path, destination_dirpath / resource['filename'])
 
 
 def _download_resources(
diff --git a/src/pymovements/exceptions.py b/src/pymovements/exceptions.py
index 6301980..51b0f14 100644
--- a/src/pymovements/exceptions.py
+++ b/src/pymovements/exceptions.py
@@ -36,3 +36,7 @@ class InvalidProperty(Exception):
     def __init__(self, property_name: str, valid_properties: list[str]):
         message = f"property '{property_name}' is invalid. Valid properties are: {valid_properties}"
         super().__init__(message)
+
+
+class UnknownFileType(RuntimeError):
+    """Raised on unknown file types."""
diff --git a/src/pymovements/utils/downloads.py b/src/pymovements/utils/downloads.py
index 19710c2..b365854 100644
--- a/src/pymovements/utils/downloads.py
+++ b/src/pymovements/utils/downloads.py
@@ -29,8 +29,7 @@ from pathlib import Path
 
 from deprecated.sphinx import deprecated
 
-from pymovements.dataset._utils._downloads import download_and_extract_archive \
-    as _download_and_extract_archive
+from pymovements.dataset._utils._archives import extract_archive as _extract_archive
 from pymovements.dataset._utils._downloads import download_file as _download_file
 
 
@@ -85,12 +84,20 @@ 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.
     """
-    return _download_and_extract_archive(
+    archive_path = _download_file(
         url=url,
-        download_dirpath=download_dirpath,
-        download_filename=download_filename,
-        extract_dirpath=extract_dirpath,
+        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,


Test patch:
diff --git a/tests/unit/dataset/_utils/_archives_test.py b/tests/unit/dataset/_utils/_archives_test.py
index d4ef0fe..0bd4e0e 100644
--- a/tests/unit/dataset/_utils/_archives_test.py
+++ b/tests/unit/dataset/_utils/_archives_test.py
@@ -348,8 +348,8 @@ def test_extract_unsupported_archive_destination_path_None(
             remove_finished=remove_finished,
         )
     msg, = excinfo.value.args
-    assert msg == """Unsupported archive type: '.jpg'.
-Supported suffixes are: '['.tar', '.zip']'."""
+    assert msg == """Unsupported compression or archive type: '.jpg.xz'.
+Supported suffixes are: '['.bz2', '.gz', '.tar', '.tbz', '.tbz2', '.tgz', '.xz', '.zip']'."""
 
 
 @pytest.mark.parametrize(
@@ -500,8 +500,8 @@ def test_extract_unsupported_archive_destination_path_not_None(
             remove_finished=remove_finished,
         )
     msg, = excinfo.value.args
-    assert msg == """Unsupported archive type: '.jpg'.
-Supported suffixes are: '['.tar', '.zip']'."""
+    assert msg == """Unsupported compression or archive type: '.jpg.xz'.
+Supported suffixes are: '['.bz2', '.gz', '.tar', '.tbz', '.tbz2', '.tgz', '.xz', '.zip']'."""
 
 
 def test_decompress_unknown_compression_suffix():
diff --git a/tests/unit/dataset/_utils/_downloads_test.py b/tests/unit/dataset/_utils/_downloads_test.py
index 9a5187f..cd5ef4a 100644
--- a/tests/unit/dataset/_utils/_downloads_test.py
+++ b/tests/unit/dataset/_utils/_downloads_test.py
@@ -26,7 +26,6 @@ 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
 
 
@@ -150,88 +149,6 @@ def test__get_redirected_url_with_redirects_max_hops():
         '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
diff --git a/tests/unit/dataset/dataset_download_test.py b/tests/unit/dataset/dataset_download_test.py
index b212d41..467168b 100644
--- a/tests/unit/dataset/dataset_download_test.py
+++ b/tests/unit/dataset/dataset_download_test.py
@@ -1438,5 +1438,5 @@ def test_dataset_download_no_precomputed_rm_resources_raises_exception(tmp_path)
 
     msg, = excinfo.value.args
 
-    expected_msg = "'precomputed_reading_measures' resources must be specified to download dataset."
+    expected_msg = 'resources must be specified to download dataset.'
     assert msg == expected_msg
diff --git a/tests/unit/utils/downloads_test.py b/tests/unit/utils/downloads_test.py
index 447c0e8..4381138 100644
--- a/tests/unit/utils/downloads_test.py
+++ b/tests/unit/utils/downloads_test.py
@@ -18,6 +18,8 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """Tests deprecated utils.downloads."""
+import hashlib
+import os.path
 import re
 
 import pytest
@@ -27,9 +29,94 @@ from pymovements.utils.downloads import download_and_extract_archive
 from pymovements.utils.downloads import download_file
 
 
+@pytest.mark.filterwarnings('ignore::DeprecationWarning')
+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.filterwarnings('ignore::DeprecationWarning')
+@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 == ''
+
+
+@pytest.mark.filterwarnings('ignore::DeprecationWarning')
+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.'
+
+
 @pytest.mark.filterwarnings('ignore::DeprecationWarning')
 @pytest.mark.parametrize('download_function', [download_and_extract_archive, download_file])
-def test_download_file(download_function, tmp_path):
+def test_deprecated_download_function(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'
 
@@ -37,7 +124,7 @@ def test_download_file(download_function, tmp_path):
 
 
 @pytest.mark.parametrize('download_function', [download_and_extract_archive, download_file])
-def test_archive_extract_deprecated(download_function, tmp_path):
+def test_is_download_function_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'
 
@@ -46,7 +133,7 @@ def test_archive_extract_deprecated(download_function, tmp_path):
 
 
 @pytest.mark.parametrize('download_function', [download_and_extract_archive, download_file])
-def test_archive_extract_removed(download_function, tmp_path):
+def test_is_download_function_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'