Repository: aeye-lab/pymovements
Pull request #217 (2023-02-28 17:16:14)

 Utils: Add paths.match_filepaths()
## Description

This function is similar to get_filepaths(),
but returns a list of match groupdicts.

This makes it possible to get rid of an unreachable Exception in Dataset.

## Implemented changes

- [x] Add paths.match_filepaths()
- [x] Use match_filepaths() in Dataset.infer_fileinfo()

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change is or requires a documentation update

## How Has This Been Tested?

Currently no tests written for this.
I will wait for tests to adjust from #178

- [x] Add tests!

## 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-commenter: # [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/217?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`84.31`**% and project coverage change: **`+2.02`** :tada:
> Comparison is base [(`da2ef3b`)](https://codecov.io/gh/aeye-lab/pymovements/commit/da2ef3bde0351530c55d794ec223aebaf971ed18?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 77.73% compared to head [(`e1cdb0e`)](https://codecov.io/gh/aeye-lab/pymovements/pull/217?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 79.76%.

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


```diff
@@            Coverage Diff             @@
##             main     #217      +/-   ##
==========================================
+ Coverage   77.73%   79.76%   +2.02%     
==========================================
  Files          21       21              
  Lines         840      855      +15     
  Branches      196      201       +5     
==========================================
+ Hits          653      682      +29     
+ Misses        167      153      -14     
  Partials       20       20              
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/217?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/utils/paths.py](https://codecov.io/gh/aeye-lab/pymovements/pull/217?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL3BhdGhzLnB5) | `43.18% <63.63%> (-11.37%)` | :arrow_down: |
| [src/pymovements/datasets/dataset.py](https://codecov.io/gh/aeye-lab/pymovements/pull/217?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2RhdGFzZXQucHk=) | `95.37% <100.00%> (+12.85%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab). 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=aeye-lab)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/217?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
: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=aeye-lab).


Patch:
diff --git a/docs/source/tutorials/dataset.ipynb b/docs/source/tutorials/dataset.ipynb
index 658dfca..e4d053e 100644
--- a/docs/source/tutorials/dataset.ipynb
+++ b/docs/source/tutorials/dataset.ipynb
@@ -15,14 +15,13 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from pymovements.datasets import JuDo1000\n",
-    "from pymovements.events import ivt, microsaccades\n",
+    "import pymovements as pm\n",
     "\n",
-    "dataset = JuDo1000(\n",
+    "dataset = pm.datasets.ToyDataset(\n",
     "    root='data/',\n",
     "    download=True,\n",
     "    extract=True,\n",
-    "    remove_finished=False,\n",
+    "    remove_finished=True,\n",
     ")"
    ]
   },
@@ -34,10 +33,10 @@
    "outputs": [],
    "source": [
     "subset = {\n",
-    "    'subject_id': [11, 22, 33, 77],\n",
-    "    'session_id': [1, 2],\n",
+    "    'text_id': [1, 2],\n",
+    "    'page_id': 1,\n",
     "}\n",
-    "dataset.load(events=False, preprocessed=False, subset=subset)"
+    "dataset.load(subset=subset)"
    ]
   },
   {
@@ -91,7 +90,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "dataset.detect_events(method=microsaccades)\n",
+    "dataset.detect_events(method=pm.events.microsaccades)\n",
     "\n",
     "dataset.events[0]"
    ]
@@ -103,7 +102,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "dataset.detect_events(method=ivt, velocity_threshold=45, minimum_duration=55)\n",
+    "dataset.detect_events(method=pm.events.ivt, velocity_threshold=45, minimum_duration=55)\n",
     "\n",
     "dataset.events[0]"
    ]
@@ -135,7 +134,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "dataset = JuDo1000(\n",
+    "dataset = pm.datasets.ToyDataset(\n",
     "    root='data/',\n",
     "    download=False,\n",
     "    extract=False,\n",
diff --git a/src/pymovements/datasets/__init__.py b/src/pymovements/datasets/__init__.py
index 98af9a3..362a8f4 100644
--- a/src/pymovements/datasets/__init__.py
+++ b/src/pymovements/datasets/__init__.py
@@ -22,3 +22,4 @@ from pymovements.datasets.dataset import Dataset  # noqa: F401
 from pymovements.datasets.gazebase import GazeBase  # noqa: F401
 from pymovements.datasets.judo1000 import JuDo1000  # noqa: F401
 from pymovements.datasets.public_dataset import PublicDataset  # noqa: F401
+from pymovements.datasets.toy_dataset import ToyDataset  # noqa: F401
diff --git a/src/pymovements/datasets/dataset.py b/src/pymovements/datasets/dataset.py
index 4fdf208..d3c5005 100644
--- a/src/pymovements/datasets/dataset.py
+++ b/src/pymovements/datasets/dataset.py
@@ -30,7 +30,7 @@ from tqdm.auto import tqdm
 from pymovements.base import Experiment
 from pymovements.events.events import Event
 from pymovements.events.events import EventDetectionCallable
-from pymovements.utils.paths import get_filepaths
+from pymovements.utils.paths import match_filepaths
 
 
 class Dataset:
@@ -171,33 +171,13 @@ class Dataset:
         RuntimeError
             If an error occurred during matching filenames or no files have been found.
         """
-        filename_regex = re.compile(self._filename_regex)
-
         # Get all filepaths that match regular expression.
-        csv_filepaths = get_filepaths(
+        fileinfo_dicts = match_filepaths(
             path=self.raw_rootpath,
-            regex=filename_regex,
+            regex=re.compile(self._filename_regex),
+            relative=True,
         )
 
-        # Parse fileinfo from filenames.
-        fileinfo_dicts: list[dict[str, Any]] = []
-        for filepath in csv_filepaths:
-
-            # All csv_filepaths already match the filename_regex.
-            match = filename_regex.match(filepath.name)
-
-            # This should never happen but mypy will complain otherwise.
-            if match is None:
-                raise RuntimeError(
-                    f'file {filepath} did not match regular expression {filename_regex}',
-                )
-
-            # We use the groupdict of the match as a base and add the filepath.
-            fileinfo_dict = match.groupdict()
-
-            fileinfo_dict['filepath'] = str(filepath.relative_to(self.raw_rootpath))
-            fileinfo_dicts.append(fileinfo_dict)
-
         if len(fileinfo_dicts) == 0:
             raise RuntimeError(f'no matching files found in {self.raw_rootpath}')
 
diff --git a/src/pymovements/datasets/toy_dataset.py b/src/pymovements/datasets/toy_dataset.py
new file mode 100644
index 0000000..ee5b492
--- /dev/null
+++ b/src/pymovements/datasets/toy_dataset.py
@@ -0,0 +1,192 @@
+# Copyright (c) 2023 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.
+"""This module provides an interface to the pymovements example toy dataset."""
+from __future__ import annotations
+
+from pathlib import Path
+
+from pymovements.base import Experiment
+from pymovements.datasets.public_dataset import PublicDataset
+
+
+class ToyDataset(PublicDataset):
+    """pymovements example toy dataset.
+
+    This dataset includes monocular eye tracking data from a single participants in a single
+    session. Eye movements are recorded at a sampling frequency of 1000 Hz using an EyeLink Portable
+    Duo video-based eye tracker and are provided as pixel coordinates.
+
+    The participant is instructed to read 4 texts with 5 screens each.
+
+    Examples
+    --------
+    Change to ``download=True`` and ``extract=True`` for downloading and extracting the dataset.
+
+    >>> dataset = ToyDataset(
+    ...     root='data/',
+    ...     download=False,
+    ...     extract=False,
+    ...     remove_finished=False,
+    ... )
+    >>> dataset.load()  # doctest: +SKIP
+    """
+    # pylint: disable=similarities
+    # The PublicDataset child classes potentially share code chunks for definitions.
+
+    _mirrors = [
+        'http://github.com/aeye-lab/pymovements-toy-dataset/zipball/',
+    ]
+
+    _resources = [
+        {
+            'resource': '6cb5d663317bf418cec0c9abe1dde5085a8a8ebd/',
+            'filename': 'pymovements-toy-dataset.zip',
+            'md5': '4da622457637a8181d86601fe17f3aa8',
+        },
+    ]
+
+    _experiment = Experiment(
+        screen_width_px=1280,
+        screen_height_px=1024,
+        screen_width_cm=38,
+        screen_height_cm=30.2,
+        distance_cm=68,
+        origin='lower left',
+        sampling_rate=1000,
+    )
+
+    _filename_regex = r'trial_(?P<text_id>\d+)_(?P<page_id>\d+).csv'
+
+    _filename_regex_dtypes = {
+        'text_id': int,
+        'page_id': int,
+    }
+
+    _column_map = {
+        'timestamp': 'time',
+        'x': 'x_right_pix',
+        'y': 'y_right_pix',
+    }
+
+    _read_csv_kwargs = {
+        'sep': '\t',
+        'columns': list(_column_map.keys()),
+        'new_columns': list(_column_map.values()),
+    }
+
+    def __init__(
+            self,
+            root: str | Path,
+            download: bool = False,
+            extract: bool = False,
+            remove_finished: bool = False,
+            dataset_dirname: str = 'ToyDataset',
+            downloads_dirname: str = 'downloads',
+            raw_dirname: str = 'raw',
+            preprocessed_dirname: str = 'preprocessed',
+            events_dirname: str = 'events',
+    ):
+        """Initialize the pymovements example toy dataset object.
+
+        If desired, dataset resources are downloaded with ``download=True`` and extracted with
+        ``extract=True``. To save space on your device you can remove the archive files after
+        successful extraction with ``remove_finished=True``.
+
+        Downloaded archives are automatically checked for integrity by comparing MD5 checksums.
+
+        You can set up a custom directory structure by populating the particular dirname attributes.
+        See :py:attr:`~pymovements.dataset.PublicDataset.dataset_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.raw_dirname`,
+        :py:attr:`~pymovements.dataset.PublicDataset.preprocessed_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.events_dirname` and
+        :py:attr:`~pymovements.dataset.PublicDataset.downloads_dirname` for details.
+
+        Parameters
+        ----------
+        root : str, Path
+            Path to the root directory of the dataset.
+        download : bool
+            Download all dataset resources.
+        extract : bool
+            Extract dataset archive files.
+        remove_finished : bool
+            Remove archive files after extraction.
+        dataset_dirname : str, optional
+            Dataset directory name under root path. Can be `.` if dataset is located in root path.
+            Default: `.`
+        downloads_dirname : str, optional
+            Name of directory to store downloaded data.Default: `downloads`
+        raw_dirname ; str, optional
+            Name of directory under dataset path that contains raw data. Can be `.` if raw data is
+            located in dataset path. We advise the user to keep the original raw data separate from
+            the preprocessed / event data. Default: `raw`
+        preprocessed_dirname : str, optional
+            Name of directory under dataset path that will be used to store preprocessed data. We
+            advise the user to keep the preprocessed data separate from the original raw data.
+            Default: `preprocessed`
+        events_dirname : str, optional
+            Name of directory under dataset path that will be used to store event data. We advise
+            the user to keep the event data separate from the original raw data. Default: `events`
+        """
+        super().__init__(
+            root=root,
+            download=download,
+            extract=extract,
+            remove_finished=remove_finished,
+            experiment=self._experiment,
+            filename_regex=self._filename_regex,
+            filename_regex_dtypes=self._filename_regex_dtypes,
+            custom_read_kwargs=self._read_csv_kwargs,
+            dataset_dirname=dataset_dirname,
+            downloads_dirname=downloads_dirname,
+            raw_dirname=raw_dirname,
+            preprocessed_dirname=preprocessed_dirname,
+            events_dirname=events_dirname,
+        )
+
+    @property
+    def path(self) -> Path:
+        """The path to the dataset directory.
+
+        The dataset path points to the dataset directory under the root path. Per default the
+        dataset directory name is equal to the class name.
+
+        Example
+        -------
+        The default behaviour is to locate the data set in a directory under the root path with the
+        same name as the class name:
+        >>> dataset = ToyDataset(root='/path/to/all/your/datasets')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/ToyDataset')
+
+        You can specify an explicit dataset directory name:
+        >>> dataset = ToyDataset(
+        ...     root='/path/to/all/your/datasets',
+        ...     dataset_dirname='toy',
+        ... )
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/all/your/datasets/toy')
+
+        You can specify to use the root path to be the actual dataset directory:
+        >>> dataset = ToyDataset(root='/path/to/toy/dataset', dataset_dirname='.')
+        >>> dataset.path  # doctest: +SKIP
+        Path('/path/to/toy/dataset')
+        """
+        return super().path
diff --git a/src/pymovements/utils/archives.py b/src/pymovements/utils/archives.py
index 30538ce..e95d4b6 100644
--- a/src/pymovements/utils/archives.py
+++ b/src/pymovements/utils/archives.py
@@ -35,10 +35,10 @@ from pymovements.utils.paths import get_filepaths
 
 
 def extract_archive(
-    source_path: Path,
-    destination_path: Path | None = None,
-    recursive: bool = True,
-    remove_finished: bool = False,
+        source_path: Path,
+        destination_path: Path | None = None,
+        recursive: bool = True,
+        remove_finished: bool = False,
 ) -> Path:
     """Extract an archive.
     The archive type and a possible compression is automatically detected from the file name.
@@ -124,16 +124,10 @@ def _extract_tar(
         archive.extractall(destination_path)
 
 
-_ZIP_COMPRESSION_MAP: dict[str, int] = {
-    '.bz2': zipfile.ZIP_BZIP2,
-    '.xz': zipfile.ZIP_LZMA,
-}
-
-
 def _extract_zip(
-    source_path: Path,
-    destination_path: Path,
-    compression: str | None,
+        source_path: Path,
+        destination_path: Path,
+        compression: str | None,
 ) -> None:
     """Extract a zip archive.
 
@@ -168,6 +162,11 @@ _COMPRESSED_FILE_OPENERS: dict[str, Callable[..., IO]] = {
     '.xz': lzma.open,
 }
 
+_ZIP_COMPRESSION_MAP: dict[str, int] = {
+    '.bz2': zipfile.ZIP_BZIP2,
+    '.xz': zipfile.ZIP_LZMA,
+}
+
 
 def _detect_file_type(filepath: Path) -> tuple[str | None, str | None]:
     """Detect the archive type and/or compression of a file.
@@ -213,8 +212,13 @@ def _detect_file_type(filepath: Path) -> tuple[str | None, str | None]:
             suffix2 = suffixes[-2]
 
             # Check if the second last suffix refers to an archive type.
-            if suffix2 in _ARCHIVE_EXTRACTORS:
-                return suffix2, suffix
+            if suffix2 not in _ARCHIVE_EXTRACTORS:
+                raise RuntimeError(
+                    f"Unsupported archive type: '{suffix2}'.\n"
+                    f"Supported suffixes are: '{sorted(set(_ARCHIVE_EXTRACTORS))}'.",
+                )
+            # We detected a compressed archive file (e.g. tar.gz).
+            return suffix2, suffix
 
         # We detected a single compressed file not an archive.
         return None, suffix
@@ -230,9 +234,9 @@ def _detect_file_type(filepath: Path) -> tuple[str | None, str | None]:
 
 
 def _decompress(
-    source_path: Path,
-    destination_path: Path | None = None,
-    remove_finished: bool = False,
+        source_path: Path,
+        destination_path: Path | None = None,
+        remove_finished: bool = False,
 ) -> Path:
     r"""Decompress a file.
 
diff --git a/src/pymovements/utils/paths.py b/src/pymovements/utils/paths.py
index 9b40c7e..061e16f 100644
--- a/src/pymovements/utils/paths.py
+++ b/src/pymovements/utils/paths.py
@@ -77,3 +77,64 @@ def get_filepaths(
                 continue
             filepaths.append(childpath)
     return filepaths
+
+
+def match_filepaths(
+        path: str | Path,
+        regex: re.Pattern,
+        relative: bool = True,
+        relative_anchor: Path | None = None,
+) -> list[dict[str, str]]:
+    """Traverse path and match regular expression.
+
+    Parameters
+    ----------
+    path: str | Path
+        Root path to be traversed.
+    regex: re.Pattern, optional
+        Regular expression filenames will be matched against.
+    relative: bool
+        If True, specify filepath as relative to root path.
+    relative_anchor: Path, optional
+        Specifies root path in case of ``relative == True``. If None, ``path`` will be chosen
+        as `relative_anchor` for recursive calls.
+
+    Returns
+    -------
+    list[dict[str, str]]
+        Each entry contains the match group dictionary of the regular expression.
+
+    Raises
+    ------
+    ValueError
+        If ``path`` does not point to a directory.
+    """
+    path = Path(path)
+    if not path.is_dir():
+        raise ValueError(f'path must point to a directory, but points to a file (path = {path})')
+
+    if relative and relative_anchor is None:
+        relative_anchor = path
+
+    match_dicts: list[dict[str, str]] = []
+    for childpath in path.iterdir():
+        if childpath.is_dir():
+            recursive_results = match_filepaths(
+                path=childpath, regex=regex,
+                relative=relative, relative_anchor=relative_anchor,
+            )
+            match_dicts.extend(recursive_results)
+        else:
+            match = regex.match(childpath.name)
+            if match is not None:
+                match_dict = match.groupdict()
+
+                filepath = childpath
+                if relative:
+                    if relative_anchor is None:
+                        relative_anchor = path
+                    filepath = filepath.relative_to(relative_anchor)
+
+                match_dict['filepath'] = str(filepath)
+                match_dicts.append(match_dict)
+    return match_dicts


Test patch:
diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py
index 9128162..a5cb080 100644
--- a/tests/datasets/test_dataset.py
+++ b/tests/datasets/test_dataset.py
@@ -344,12 +344,6 @@ def test_init_exceptions(init_kwargs, exception):
 @pytest.mark.parametrize(
     'init_kwargs, load_kwargs, exception',
     [
-        pytest.param(
-            {'root': '/not/a/real/path'},
-            {},
-            RuntimeError,
-            id='no_files_present',
-        ),
         pytest.param(
             {},
             {'subset': 1},
@@ -384,6 +378,17 @@ def test_load_exceptions(init_kwargs, load_kwargs, exception, dataset_configurat
         dataset.load(**load_kwargs)
 
 
+def test_load_no_files_raises_exception(dataset_configuration):
+    init_kwargs = {**dataset_configuration['init_kwargs']}
+    dataset = Dataset(**init_kwargs)
+
+    shutil.rmtree(dataset.raw_rootpath, ignore_errors=True)
+    dataset.raw_rootpath.mkdir()
+
+    with pytest.raises(RuntimeError):
+        dataset.load()
+
+
 @pytest.mark.parametrize(
     'exception',
     [
diff --git a/tests/datasets/test_toy_dataset.py b/tests/datasets/test_toy_dataset.py
new file mode 100644
index 0000000..64cb43a
--- /dev/null
+++ b/tests/datasets/test_toy_dataset.py
@@ -0,0 +1,70 @@
+# Copyright (c) 2023 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 all functionality in pymovements.datasets.judo1000."""
+from pathlib import Path
+
+import pytest
+
+from pymovements.datasets.toy_dataset import ToyDataset
+
+
+@pytest.mark.parametrize(
+    'init_kwargs, expected_paths',
+    [
+        pytest.param(
+            {'root': '/data/set/path'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/ToyDataset'),
+                'download': Path('/data/set/path/ToyDataset/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': '.'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/'),
+                'download': Path('/data/set/path/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'dataset_dirname': 'dataset'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/dataset'),
+                'download': Path('/data/set/path/dataset/downloads'),
+            },
+        ),
+        pytest.param(
+            {'root': '/data/set/path', 'downloads_dirname': 'custom_download_dirname'},
+            {
+                'root': Path('/data/set/path/'),
+                'path': Path('/data/set/path/ToyDataset'),
+                'download': Path('/data/set/path/ToyDataset/custom_download_dirname'),
+            },
+        ),
+    ],
+)
+def test_paths(init_kwargs, expected_paths):
+    dataset = ToyDataset(**init_kwargs)
+
+    assert dataset.root == expected_paths['root']
+    assert dataset.path == expected_paths['path']
+    assert dataset.downloads_rootpath == expected_paths['download']
diff --git a/tests/utils/test_archives.py b/tests/utils/test_archives.py
new file mode 100644
index 0000000..ee94fff
--- /dev/null
+++ b/tests/utils/test_archives.py
@@ -0,0 +1,283 @@
+# Copyright (c) 2022-2023 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 archives.
+"""
+import bz2
+import gzip
+import lzma
+import pathlib
+import tarfile
+import zipfile
+
+import pytest
+
+from pymovements.utils.archives import _ZIP_COMPRESSION_MAP
+from pymovements.utils.archives import extract_archive
+
+
+def test_extract_archive_wrong_suffix():
+    """
+    Test unsupported suffix for extract_archive()
+    """
+    with pytest.raises(RuntimeError) as excinfo:
+        extract_archive(pathlib.Path('test.jpg'))
+    msg, = excinfo.value.args
+    assert msg == """Unsupported compression or archive type: '.jpg'.
+Supported suffixes are: '['.bz2', '.gz', '.tar', '.tbz', '.tbz2', '.tgz', '.xz', '.zip']'."""
+
+
+def test_detect_file_type_no_suffixes():
+    """
+    Test extract_archive() for no files with suffix
+    """
+    with pytest.raises(RuntimeError) as excinfo:
+        extract_archive(pathlib.Path('test'))
+    msg, = excinfo.value.args
+    assert msg == "File 'test' has no suffixes that could be used to "\
+        'detect the archive type or compression.'
+
+
+@pytest.fixture(
+    name='archive',
+    params=[
+        (None, 'tar'),
+        (None, 'zip'),
+        ('bz2', None),
+        ('gz', None),
+        ('tbz', None),
+        ('tbz2', None),
+        ('tgz', None),
+        ('xz', None),
+        ('bz2', 'tar'),
+        ('bz2', 'zip'),
+        ('gz', 'tar'),
+        ('xz', 'tar'),
+        ('xz', 'zip'),
+    ],
+    ids=[
+        'tar_archive',
+        'zip_archive',
+        'bz2_compressed_archive',
+        'gz_compressed_archive',
+        'tbz_compressed_archive',
+        'tbz2_compressed_archive',
+        'tgz_compressed_archive',
+        'xz_compressed_archive',
+        'bz2_compressed_tar_archive',
+        'bz2_compressed_zip_archive',
+        'gz_compressed_tar_archive',
+        'xz_compressed_tar_archive',
+        'xz_compressed_zip_archive',
+    ],
+)
+def fixture_archive(request, tmp_path):
+    rootpath = tmp_path
+    compression, extension = request.param
+
+    # write tmp filepath
+    filepath = rootpath / 'test.file'
+    if extension in {'zip', 'tar'}:
+        filepath.write_text('test')
+    if compression in {'bz2', 'xz', 'tbz', 'tbz2', 'tgz', 'gz'} and extension is None:
+        filepath.write_bytes(b'test')
+
+    # declare archive path
+    if compression is None:
+        archive_path = rootpath / f'test.{extension}'
+    elif compression is not None and extension is None:
+        archive_path = rootpath / f'test.{compression}'
+    elif compression is not None and extension is not None:
+        archive_path = rootpath / f'test.{extension}.{compression}'
+
+    if compression is None and extension == 'zip':
+        with zipfile.ZipFile(archive_path, 'w') as zip_open:
+            zip_open.write(filepath)
+        yield archive_path
+
+    elif compression is not None and extension == 'zip':
+        comp_type = _ZIP_COMPRESSION_MAP[f'.{compression}']
+        with zipfile.ZipFile(archive_path, 'w', compression=comp_type) as zip_open:
+            zip_open.write(filepath)
+        yield archive_path
+
+    elif compression is None and extension == 'tar':
+        with tarfile.TarFile.open(archive_path, 'w') as fp:
+            fp.add(filepath)
+        yield archive_path
+
+    elif (
+        (compression is not None and extension == 'tar') or
+        (compression in {'tbz', 'tbz2', 'tgz'})
+    ):
+        if compression in {'tbz', 'tbz2'}:
+            compression = 'bz2'
+        if compression in {'tgz'}:
+            compression = 'gz'
+        with tarfile.TarFile.open(archive_path, f'w:{compression}') as fp:
+            fp.add(filepath)
+        yield archive_path
+
+    elif compression == 'bz2' and extension is None:
+        with bz2.open(archive_path, 'wb') as fp:
+            fp.write(filepath.read_bytes())
+        yield archive_path
+
+    elif compression == 'gz' and extension is None:
+        with gzip.open(archive_path, 'wb') as fp:
+            fp.write(filepath.read_bytes())
+        yield archive_path
+
+    elif compression == 'xz' and extension is None:
+        with lzma.open(archive_path, 'wb') as fp:
+            fp.write(filepath.read_bytes())
+        yield archive_path
+
+    else:
+        raise ValueError(f'{request.param} not supported for archive fixture')
+
+
+@pytest.fixture(
+    name='unsupported_archive',
+    params=[
+        ('xz', 'jpg'),
+    ],
+    ids=[
+        'xz_compressed_unsupported_archive',
+    ],
+)
+def fixture_unsupported_archive(request, tmp_path):
+    rootpath = tmp_path
+    compression, extension = request.param
+
+    # write tmp filepath
+    filepath = rootpath / 'test.file'
+    filepath.write_text('test')
+    archive_path = rootpath / f'test.{extension}.{compression}'
+    comp_type = _ZIP_COMPRESSION_MAP[f'.{compression}']
+    with zipfile.ZipFile(archive_path, 'w', compression=comp_type) as zip_open:
+        zip_open.write(filepath)
+    yield archive_path
+
+
+@pytest.mark.parametrize(
+    'recursive',
+    [
+        pytest.param(False, id='recursive_false'),
+        pytest.param(True, id='recursive_true'),
+    ],
+)
+@pytest.mark.parametrize(
+    'remove_finished',
+    [
+        pytest.param(False, id='remove_finished_false'),
+        pytest.param(True, id='remove_finished_true'),
+    ],
+)
+def test_extract_archive_destination_path_None(recursive, remove_finished, archive):
+    extract_archive(
+        source_path=archive,
+        destination_path=None,
+        recursive=recursive,
+        remove_finished=remove_finished,
+    )
+
+
+@pytest.mark.parametrize(
+    'recursive',
+    [
+        pytest.param(False, id='recursive_false'),
+        pytest.param(True, id='recursive_true'),
+    ],
+)
+@pytest.mark.parametrize(
+    'remove_finished',
+    [
+        pytest.param(False, id='remove_finished_false'),
+        pytest.param(True, id='remove_finished_true'),
+    ],
+)
+def test_extract_unsupported_archive_destination_path_None(
+    recursive, remove_finished, unsupported_archive,
+):
+    with pytest.raises(RuntimeError) as excinfo:
+        extract_archive(
+            source_path=unsupported_archive,
+            destination_path=None,
+            recursive=recursive,
+            remove_finished=remove_finished,
+        )
+    msg, = excinfo.value.args
+    assert msg == """Unsupported archive type: '.jpg'.
+Supported suffixes are: '['.tar', '.zip']'."""
+
+
+@pytest.mark.parametrize(
+    'recursive',
+    [
+        pytest.param(False, id='recursive_false'),
+        pytest.param(True, id='recursive_true'),
+    ],
+)
+@pytest.mark.parametrize(
+    'remove_finished',
+    [
+        pytest.param(False, id='remove_finished_false'),
+        pytest.param(True, id='remove_finished_true'),
+    ],
+)
+def test_extract_archive_destination_path_not_None(recursive, remove_finished, archive, tmp_path):
+    destination_path = tmp_path / pathlib.Path('tmpfoo')
+    extract_archive(
+        source_path=archive,
+        destination_path=destination_path,
+        recursive=recursive,
+        remove_finished=remove_finished,
+    )
+
+
+@pytest.mark.parametrize(
+    'recursive',
+    [
+        pytest.param(False, id='recursive_false'),
+        pytest.param(True, id='recursive_true'),
+    ],
+)
+@pytest.mark.parametrize(
+    'remove_finished',
+    [
+        pytest.param(False, id='remove_finished_false'),
+        pytest.param(True, id='remove_finished_true'),
+    ],
+)
+def test_extract_unnsupported_archive_destination_path_not_None(
+        recursive, remove_finished, unsupported_archive, tmp_path,
+):
+    destination_path = tmp_path / pathlib.Path('tmpfoo')
+    with pytest.raises(RuntimeError) as excinfo:
+        extract_archive(
+            source_path=unsupported_archive,
+            destination_path=destination_path,
+            recursive=recursive,
+            remove_finished=remove_finished,
+        )
+    msg, = excinfo.value.args
+    assert msg == """Unsupported archive type: '.jpg'.
+Supported suffixes are: '['.tar', '.zip']'."""
diff --git a/tests/utils/test_paths.py b/tests/utils/test_paths.py
index 8b6c876..e80e9f2 100644
--- a/tests/utils/test_paths.py
+++ b/tests/utils/test_paths.py
@@ -22,10 +22,13 @@ Test pymovements paths.
 """
 import pathlib
 import re
+import unittest
+from pathlib import Path
 
 import pytest
 
 from pymovements.utils.paths import get_filepaths
+from pymovements.utils.paths import match_filepaths
 
 
 def test_get_filepaths_mut_excl_extension_and_regex_error():
@@ -45,13 +48,13 @@ def test_get_filepaths_mut_excl_extension_and_regex_error():
             None,
             re.compile('regex'),
             [],
-            id='regex empty dir',
+            id='regex_empty_dir',
         ),
         pytest.param(
             'extension',
             None,
             [],
-            id='extension empty dir',
+            id='extension_empty_dir',
         ),
     ],
 )
@@ -60,9 +63,6 @@ def test_get_filepaths_empty_directory(
         regex,
         expected_paths,
 ):
-    """
-    Test mutually exclusive extension and regex in `get_filepaths`.
-    """
     assert get_filepaths(path='tmp', extension=extension, regex=regex) == expected_paths
 
 
@@ -84,7 +84,7 @@ def create_directory(tmp_path, sub_dirs, files):
             ['tmp_dir'],
             ['foo.txt', 'bar.py'],
             ['tmp_dir/foo.txt'],
-            id='extension regex get filepaths list',
+            id='extension_regex_get_filepaths_list',
         ),
         pytest.param(
             None,
@@ -92,11 +92,11 @@ def create_directory(tmp_path, sub_dirs, files):
             ['tmp_dir'],
             ['foo.txt', 'foo.py', 'bar.py'],
             ['tmp_dir/foo.txt', 'tmp_dir/foo.py'],
-            id='regex get filepaths list',
+            id='regex_get_filepaths_list',
         ),
     ],
 )
-def test_get_filepaths_is_dir(
+def test_get_filepaths_expected_output(
         extension,
         regex,
         tmp_path,
@@ -104,10 +104,85 @@ def test_get_filepaths_is_dir(
         files,
         expected_paths,
 ):
-    """
-    Test `get_filepaths` list creation.
-    """
     create_directory(tmp_path, sub_dirs, files)
     ret = get_filepaths(path=tmp_path, extension=extension, regex=regex)
     expected_list = [tmp_path / pathlib.Path(expected_path) for expected_path in expected_paths]
     assert sorted(ret) == sorted(expected_list)
+
+
+@pytest.mark.parametrize(
+    ('regex', 'sub_dirs', 'files', 'expected_dicts'),
+    [
+        pytest.param(
+            re.compile('.*'),
+            ['tmp_dir'],
+            [],
+            [],
+            id='empty_directory_empty_list',
+        ),
+        pytest.param(
+            re.compile('.*'),
+            ['tmp_dir'],
+            ['foo.txt'],
+            [{'filepath': str(Path('tmp_dir/foo.txt'))}],
+            id='match_all_no_groups_single_file',
+        ),
+        pytest.param(
+            re.compile('.*'),
+            ['tmp_dir'],
+            ['foo.txt', 'bar.py'],
+            [{'filepath': str(Path('tmp_dir/foo.txt'))}, {'filepath': str(Path('tmp_dir/bar.py'))}],
+            id='match_all_no_groups_two_files',
+        ),
+        pytest.param(
+            re.compile('foo'),
+            ['tmp_dir'],
+            ['foo.txt', 'bar.py'],
+            [{'filepath': str(Path('tmp_dir/foo.txt'))}],
+            id='match_substring_single_file_out_of_two_no_groups',
+        ),
+        pytest.param(
+            re.compile(r'(?P<foo>\d+)_(?P<bar>\d+).ext'),
+            ['a'],
+            ['123_456.ext', '456_789.ext'],
+            [
+                {'foo': '123', 'bar': '456', 'filepath': str(Path('a/123_456.ext'))},
+                {'foo': '456', 'bar': '789', 'filepath': str(Path('a/456_789.ext'))},
+            ],
+            id='match_groups_two_files',
+        ),
+    ],
+)
+def test_match_filepaths(
+        regex,
+        sub_dirs,
+        files,
+        expected_dicts,
+        tmp_path,
+):
+    create_directory(tmp_path, sub_dirs, files)
+    result_dicts = match_filepaths(path=tmp_path, regex=regex)
+
+    case = unittest.TestCase()
+    case.assertCountEqual(result_dicts, expected_dicts)
+
+
+def test_match_filepaths_not_relative(tmp_path):
+    create_directory(tmp_path, ['tmp_dir'], ['foo.txt'])
+    result_dicts = match_filepaths(path=tmp_path, regex=re.compile('.*'), relative=False)
+
+    expected_dicts = [{'filepath': str(tmp_path / 'tmp_dir/foo.txt')}]
+    case = unittest.TestCase()
+    case.assertCountEqual(result_dicts, expected_dicts)
+
+
+def test_match_filepaths_no_directory_raises_value_error(tmp_path):
+    create_directory(tmp_path, ['tmp_dir'], ['foo.txt'])
+    filepath = tmp_path / 'tmp_dir' / 'foo.txt'
+
+    with pytest.raises(ValueError) as excinfo:
+        match_filepaths(path=filepath, regex=re.compile('.*'))
+    msg, = excinfo.value.args
+
+    assert 'must point to a directory' in msg
+    assert str(filepath) in msg