Repository: aeye-lab/pymovements
Pull request #230 (2023-03-02 16:09:08)

Tests: add test coverage for pymovements.utils.archives
added test coverage for pymovements.utils.archives.

I had to rewrite the source file a bit to make pylint happy :)

codecov-commenter: # [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`100.00`**% and project coverage change: **`+8.99`** :tada:
> Comparison is base [(`d57099c`)](https://codecov.io/gh/aeye-lab/pymovements/commit/d57099ccaead43fc773a1d89931fc689ededd09c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 82.12% compared to head [(`b943d93`)](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 91.12%.

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


```diff
@@            Coverage Diff             @@
##             main     #230      +/-   ##
==========================================
+ Coverage   82.12%   91.12%   +8.99%     
==========================================
  Files          22       22              
  Lines         895      901       +6     
  Branches      211      210       -1     
==========================================
+ Hits          735      821      +86     
+ Misses        139       74      -65     
+ Partials       21        6      -15     
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/230?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/archives.py](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL2FyY2hpdmVzLnB5) | `97.14% <100.00%> (+67.73%)` | :arrow_up: |
| [src/pymovements/utils/downloads.py](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL2Rvd25sb2Fkcy5weQ==) | `25.00% <0.00%> (ø)` | |
| [src/pymovements/datasets/dataset.py](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXRzL2RhdGFzZXQucHk=) | `97.36% <0.00%> (+0.92%)` | :arrow_up: |
| [src/pymovements/events/engbert.py](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9lbmdiZXJ0LnB5) | `100.00% <0.00%> (+4.00%)` | :arrow_up: |
| [src/pymovements/utils/filters.py](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL2ZpbHRlcnMucHk=) | `100.00% <0.00%> (+12.90%)` | :arrow_up: |
| [src/pymovements/transforms.py](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3RyYW5zZm9ybXMucHk=) | `100.00% <0.00%> (+14.95%)` | :arrow_up: |
| [src/pymovements/utils/paths.py](https://codecov.io/gh/aeye-lab/pymovements/pull/230?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL3BhdGhzLnB5) | `100.00% <0.00%> (+45.45%)` | :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/230?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 5c91ed3..4fdf208 100644
--- a/src/pymovements/datasets/dataset.py
+++ b/src/pymovements/datasets/dataset.py
@@ -377,11 +377,9 @@ class Dataset:
     def pix2deg(self, verbose: bool = True) -> None:
         """Compute gaze positions in degrees of visual angle from pixel coordinates.
 
-        This requires an experiment definition and also assumes that the columns 'x_left_pix',
-         'y_left_pix', 'x_right_pix' and 'y_right_pix' are available in the gaze dataframe.
+        This method requires a properly initialized :py:attr:`~.Dataset.experiment` attribute.
 
-        After success, the gaze dataframe is extended by the columns 'x_left_dva', 'y_left_dva',
-        'x_right_dva' and, 'y_right_dva'.
+        After success, the gaze dataframe is extended by the resulting dva columns.
 
         Parameters
         ----------
@@ -399,29 +397,29 @@ class Dataset:
 
         disable_progressbar = not verbose
 
-        for file_id, file_df in enumerate(tqdm(self.gaze, disable=disable_progressbar)):
-            pix_position_columns = ['x_left_pix', 'y_left_pix', 'x_right_pix', 'y_right_pix']
-            dva_position_columns = ['x_left_dva', 'y_left_dva', 'x_right_dva', 'y_right_dva']
+        pix_position_columns = self.pix_position_columns
+        dva_position_columns = self._pixel_to_dva_position_columns(pix_position_columns)
 
+        for file_id, file_df in enumerate(tqdm(self.gaze, disable=disable_progressbar)):
             pixel_positions = file_df.select(pix_position_columns)
 
             dva_positions = self.experiment.screen.pix2deg(  # type: ignore[union-attr]
                 pixel_positions.to_numpy(),
             )
 
-            for dva_column_id, dva_column_name in enumerate(dva_position_columns):
-                self.gaze[file_id] = self.gaze[file_id].with_columns(
-                    pl.Series(name=dva_column_name, values=dva_positions[:, dva_column_id]),
-                )
+            self.gaze[file_id] = self.gaze[file_id].with_columns(
+                [
+                    pl.Series(name=dva_column_name, values=dva_positions[:, dva_column_id])
+                    for dva_column_id, dva_column_name in enumerate(dva_position_columns)
+                ],
+            )
 
     def pos2vel(self, method: str = 'smooth', verbose: bool = True, **kwargs) -> None:
         """Compute gaze velocites in dva/s from dva coordinates.
 
-        This requires an experiment definition and also assumes that the columns 'x_left_dva',
-         'y_left_dva', 'x_right_dva' and 'y_right_dva' are available in the gaze dataframe.
+        This method requires a properly initialized :py:attr:`~.Dataset.experiment` attribute.
 
-        After success, the gaze dataframe is extended by the columns 'x_left_vel', 'y_left_vel',
-        'x_right_vel' and, 'y_right_vel'.
+        After success, the gaze dataframe is extended by the resulting velocity columns.
 
         Parameters
         ----------
@@ -443,20 +441,22 @@ class Dataset:
 
         disable_progressbar = not verbose
 
-        for file_id, file_df in enumerate(tqdm(self.gaze, disable=disable_progressbar)):
-            position_columns = ['x_left_dva', 'y_left_dva', 'x_right_dva', 'y_right_dva']
-            velocity_columns = ['x_left_vel', 'y_left_vel', 'x_right_vel', 'y_right_vel']
+        position_columns = self.dva_position_columns
+        velocity_columns = self._position_to_velocity_columns(position_columns)
 
+        for file_id, file_df in enumerate(tqdm(self.gaze, disable=disable_progressbar)):
             positions = file_df.select(position_columns)
 
             velocities = self.experiment.pos2vel(  # type: ignore[union-attr]
                 positions.to_numpy(), method=method, **kwargs,
             )
 
-            for col_id, velocity_column_name in enumerate(velocity_columns):
-                self.gaze[file_id] = self.gaze[file_id].with_columns(
-                    pl.Series(name=velocity_column_name, values=velocities[:, col_id]),
-                )
+            self.gaze[file_id] = self.gaze[file_id].with_columns(
+                [
+                    pl.Series(name=velocity_column_name, values=velocities[:, column_id])
+                    for column_id, velocity_column_name in enumerate(velocity_columns)
+                ],
+            )
 
     def detect_events(
             self,
@@ -473,9 +473,9 @@ class Dataset:
         method : EventDetectionCallable
             The event detection method to be applied.
         eye : str
-            Select which eye to choose. Valid options are ``auto``, ``left`` or ``right``. If
-            ``auto`` is passed, ``left`` will only be chosen if the right eye is not available in
-            the gaze data frame.
+            Select which eye to choose. Valid options are ``auto``, ``left``, ``right`` or ``eye``.
+            If ``auto`` is passed, eye is inferred in the order ``['right', 'left', 'eye']`` from
+            the available :py:attr:`~.Dataset.gaze` dataframe columns.
         clear : bool
             If ``True``, event DataFrame will be overwritten with new DataFrame instead of being
              merged into the existing one.
@@ -497,6 +497,8 @@ class Dataset:
                 eye = 'right'
             elif 'x_left_dva' in self.gaze[0].columns:
                 eye = 'left'
+            elif 'x_eye_dva' in self.gaze[0].columns:
+                eye = 'eye'
             else:
                 raise AttributeError(
                     'Either right or left eye columns must be present in gaze data frame.'
@@ -506,6 +508,13 @@ class Dataset:
         position_columns = [f'x_{eye}_dva', f'y_{eye}_dva']
         velocity_columns = [f'x_{eye}_vel', f'y_{eye}_vel']
 
+        if not set(position_columns).issubset(set(self.gaze[0].columns)):
+            raise AttributeError(
+                f'{eye} eye specified but required columns are not available in gaze dataframe.'
+                f' required columns: {position_columns}'
+                f', available columns: {self.gaze[0].columns}',
+            )
+
         disable_progressbar = not verbose
 
         event_dfs: list[pl.DataFrame] = []
@@ -760,6 +769,33 @@ class Dataset:
         """
         return self.path / self.raw_dirname
 
+    @property
+    def pix_position_columns(self) -> list[str]:
+        """Get pixel position columns for this dataset."""
+        pix_position_columns = [
+            'x_left_pix', 'y_left_pix', 'x_right_pix', 'y_right_pix', 'x_eye_pix', 'y_eye_pix',
+        ]
+        pix_position_columns = list(set(pix_position_columns) & set(self.gaze[0].columns))
+        return pix_position_columns
+
+    @property
+    def dva_position_columns(self) -> list[str]:
+        """Get dva position columns for this dataset."""
+        dva_position_columns = [
+            'x_left_dva', 'y_left_dva', 'x_right_dva', 'y_right_dva', 'x_eye_dva', 'y_eye_dva',
+        ]
+        dva_position_columns = list(set(dva_position_columns) & set(self.gaze[0].columns))
+        return dva_position_columns
+
+    @property
+    def velocity_columns(self) -> list[str]:
+        """Get velocity columns for this dataset."""
+        velocity_columns = [
+            'x_left_vel', 'y_left_vel', 'x_right_vel', 'y_right_vel', 'x_eye_vel', 'y_eye_vel',
+        ]
+        velocity_columns = list(set(velocity_columns) & set(self.gaze[0].columns))
+        return velocity_columns
+
     def _check_fileinfo(self) -> None:
         """Check if fileinfo attribute is set and there is at least one row present."""
         if self.fileinfo is None:
@@ -855,6 +891,22 @@ class Dataset:
 
         return events_file_dirpath / events_filename
 
+    def _pixel_to_dva_position_columns(self, columns: list[str]) -> list[str]:
+        """Get corresponding dva position columns from pixel position columns."""
+        return [
+            column.replace('_pix', '_dva')
+            for column in columns
+            if column.endswith('_pix')
+        ]
+
+    def _position_to_velocity_columns(self, columns: list[str]) -> list[str]:
+        """Get corresponding velocity columns from dva position columns."""
+        return [
+            column.replace('_dva', '_vel')
+            for column in columns
+            if column.endswith('_dva')
+        ]
+
     def _add_fileinfo(self, df: pl.DataFrame, fileinfo: dict[str, Any]) -> pl.DataFrame:
         """Add columns from fileinfo to dataframe.
 
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.
 


Test patch:
diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py
index 2898d9d..9128162 100644
--- a/tests/datasets/test_dataset.py
+++ b/tests/datasets/test_dataset.py
@@ -19,6 +19,7 @@
 # SOFTWARE.
 """Test all functionality in pymovements.datasets.dataset."""
 import shutil
+import unittest
 from pathlib import Path
 
 import numpy as np
@@ -76,7 +77,7 @@ def create_event_files_from_fileinfo(gaze_dfs, fileinfo, rootpath):
         gaze_df.write_ipc(rootpath / filepath)
 
 
-def mock_toy(rootpath, raw_fileformat='csv'):
+def mock_toy(rootpath, raw_fileformat, eyes):
     subject_ids = list(range(1, 21))
 
     fileinfo = pl.DataFrame(data={'subject_id': subject_ids}, schema={'subject_id': pl.Int64})
@@ -89,24 +90,73 @@ def mock_toy(rootpath, raw_fileformat='csv'):
 
     gaze_dfs = []
     for fileinfo_row in fileinfo.to_dicts():
-        gaze_df = pl.from_dict(
-            {
-                'subject_id': fileinfo_row['subject_id'],
-                'time': np.arange(1000),
-                'x_left_pix': np.zeros(1000),
-                'y_left_pix': np.zeros(1000),
-                'x_right_pix': np.zeros(1000),
-                'y_right_pix': np.zeros(1000),
-            },
-            schema={
-                'subject_id': pl.Int64,
-                'time': pl.Int64,
-                'x_left_pix': pl.Float64,
-                'y_left_pix': pl.Float64,
-                'x_right_pix': pl.Float64,
-                'y_right_pix': pl.Float64,
-            },
-        )
+        if eyes == 'both':
+            gaze_df = pl.from_dict(
+                {
+                    'subject_id': fileinfo_row['subject_id'],
+                    'time': np.arange(1000),
+                    'x_left_pix': np.zeros(1000),
+                    'y_left_pix': np.zeros(1000),
+                    'x_right_pix': np.zeros(1000),
+                    'y_right_pix': np.zeros(1000),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_left_pix': pl.Float64,
+                    'y_left_pix': pl.Float64,
+                    'x_right_pix': pl.Float64,
+                    'y_right_pix': pl.Float64,
+                },
+            )
+        elif eyes == 'left':
+            gaze_df = pl.from_dict(
+                {
+                    'subject_id': fileinfo_row['subject_id'],
+                    'time': np.arange(1000),
+                    'x_left_pix': np.zeros(1000),
+                    'y_left_pix': np.zeros(1000),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_left_pix': pl.Float64,
+                    'y_left_pix': pl.Float64,
+                },
+            )
+        elif eyes == 'right':
+            gaze_df = pl.from_dict(
+                {
+                    'subject_id': fileinfo_row['subject_id'],
+                    'time': np.arange(1000),
+                    'x_right_pix': np.zeros(1000),
+                    'y_right_pix': np.zeros(1000),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_right_pix': pl.Float64,
+                    'y_right_pix': pl.Float64,
+                },
+            )
+        elif eyes == 'eye':
+            gaze_df = pl.from_dict(
+                {
+                    'subject_id': fileinfo_row['subject_id'],
+                    'time': np.arange(1000),
+                    'x_eye_pix': np.zeros(1000),
+                    'y_eye_pix': np.zeros(1000),
+                },
+                schema={
+                    'subject_id': pl.Int64,
+                    'time': pl.Int64,
+                    'x_eye_pix': pl.Float64,
+                    'y_eye_pix': pl.Float64,
+                },
+            )
+        else:
+            raise ValueError(f'invalid value for eyes: {eyes}')
+
         gaze_dfs.append(gaze_df)
 
     create_raw_gaze_files_from_fileinfo(gaze_dfs, fileinfo, rootpath / 'raw')
@@ -187,17 +237,24 @@ def mock_toy(rootpath, raw_fileformat='csv'):
         'raw_gaze_dfs': gaze_dfs,
         'preprocessed_gaze_dfs': preprocessed_gaze_dfs,
         'event_dfs': event_dfs,
+        'eyes': eyes,
     }
 
 
-@pytest.fixture(name='dataset_configuration', params=['Toy'])
+@pytest.fixture(name='dataset_configuration', params=['ToyMono', 'ToyBino', 'ToyLeft', 'ToyRight'])
 def fixture_dataset(request, tmp_path):
     rootpath = tmp_path
 
-    if request.param == 'Toy':
-        dataset_dict = mock_toy(rootpath, raw_fileformat='csv')
+    if request.param == 'ToyBino':
+        dataset_dict = mock_toy(rootpath, raw_fileformat='csv', eyes='both')
+    elif request.param == 'ToyMono':
+        dataset_dict = mock_toy(rootpath, raw_fileformat='csv', eyes='eye')
+    elif request.param == 'ToyLeft':
+        dataset_dict = mock_toy(rootpath, raw_fileformat='csv', eyes='left')
+    elif request.param == 'ToyRight':
+        dataset_dict = mock_toy(rootpath, raw_fileformat='csv', eyes='right')
     elif request.param == 'ToyMat':
-        dataset_dict = mock_toy(rootpath, raw_fileformat='mat')
+        dataset_dict = mock_toy(rootpath, raw_fileformat='mat', eyes='both')
     else:
         raise ValueError(f'{request.param} not supported as dataset mock')
 
@@ -349,15 +406,16 @@ def test_pix2deg(dataset_configuration):
 
     dataset.pix2deg()
 
-    expected_schema = {
-        **original_schema,
-        'x_left_dva': pl.Float64,
-        'y_left_dva': pl.Float64,
-        'x_right_dva': pl.Float64,
-        'y_right_dva': pl.Float64,
-    }
+    dva_schema = {}
+    for column_name in original_schema.keys():
+        if column_name.endswith('_pix'):
+            dva_column_name = column_name.replace('_pix', '_dva')
+            dva_schema[dva_column_name] = original_schema[column_name]
+    expected_schema = {**original_schema, **dva_schema}
 
     for result_gaze_df in dataset.gaze:
+        print(result_gaze_df.schema)
+        print(expected_schema)
         assert result_gaze_df.schema == expected_schema
 
 
@@ -370,13 +428,12 @@ def test_pos2vel(dataset_configuration):
 
     dataset.pos2vel()
 
-    expected_schema = {
-        **original_schema,
-        'x_left_vel': pl.Float64,
-        'y_left_vel': pl.Float64,
-        'x_right_vel': pl.Float64,
-        'y_right_vel': pl.Float64,
-    }
+    vel_schema = {}
+    for column_name in original_schema.keys():
+        if column_name.endswith('_pix'):
+            vel_column_name = column_name.replace('_pix', '_vel')
+            vel_schema[vel_column_name] = original_schema[column_name]
+    expected_schema = {**original_schema, **vel_schema}
 
     for result_gaze_df in dataset.gaze:
         assert result_gaze_df.schema == expected_schema
@@ -391,15 +448,32 @@ def test_pos2vel(dataset_configuration):
                 'threshold': 1,
                 'eye': 'auto',
             },
-            id='1',
+            id='microsaccades',
         ),
+    ],
+)
+def test_detect_events_auto_eye(detect_event_kwargs, dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+    dataset.pix2deg()
+    dataset.pos2vel()
+    dataset.detect_events(**detect_event_kwargs)
+
+    expected_schema = {'subject_id': pl.Int64, **Saccade.schema}
+    for result_event_df in dataset.events:
+        assert result_event_df.schema == expected_schema
+
+
+@pytest.mark.parametrize(
+    'detect_event_kwargs',
+    [
         pytest.param(
             {
                 'method': microsaccades,
                 'threshold': 1,
                 'eye': 'left',
             },
-            id='2',
+            id='left',
         ),
         pytest.param(
             {
@@ -407,20 +481,42 @@ def test_pos2vel(dataset_configuration):
                 'threshold': 1,
                 'eye': 'right',
             },
-            id='3',
+            id='right',
+        ),
+        pytest.param(
+            {
+                'method': microsaccades,
+                'threshold': 1,
+                'eye': 'eye',
+            },
+            id='eye',
         ),
     ],
 )
-def test_detect_events(detect_event_kwargs, dataset_configuration):
+def test_detect_events_explicit_eye(detect_event_kwargs, dataset_configuration):
     dataset = Dataset(**dataset_configuration['init_kwargs'])
     dataset.load()
     dataset.pix2deg()
     dataset.pos2vel()
-    dataset.detect_events(**detect_event_kwargs)
 
-    expected_schema = {'subject_id': pl.Int64, **Saccade.schema}
-    for result_event_df in dataset.events:
-        assert result_event_df.schema == expected_schema
+    dataset_eyes = dataset_configuration['eyes']
+
+    exception = None
+    if dataset_eyes != detect_event_kwargs['eye']:
+        if dataset_eyes != 'both' or detect_event_kwargs['eye'] == 'eye':
+            exception = AttributeError
+
+    if exception is None:
+        dataset.detect_events(**detect_event_kwargs)
+
+        expected_schema = {'subject_id': pl.Int64, **Saccade.schema}
+
+        for result_event_df in dataset.events:
+            assert result_event_df.schema == expected_schema
+
+    else:
+        with pytest.raises(exception):
+            dataset.detect_events(**detect_event_kwargs)
 
 
 @pytest.mark.parametrize(
@@ -444,7 +540,7 @@ def test_detect_events(detect_event_kwargs, dataset_configuration):
             {
                 'method': microsaccades,
                 'threshold': 1,
-                'eye': 'left',
+                'eye': 'auto',
             },
             {
                 'method': ivt,
@@ -471,6 +567,35 @@ def test_detect_events_multiple_calls(
         assert result_event_df.schema == expected_schema
 
 
+def test_detect_events_attribute_error(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load()
+
+    try:
+        dataset.gaze[0] = dataset.gaze[0].drop('x_left_dva')
+    except BaseException:
+        pass
+
+    try:
+        dataset.gaze[0] = dataset.gaze[0].drop('x_right_dva')
+    except BaseException:
+        pass
+
+    try:
+        dataset.gaze[0] = dataset.gaze[0].drop('x_eye_dva')
+    except BaseException:
+        pass
+
+    detect_event_kwargs = {
+        'method': microsaccades,
+        'threshold': 1,
+        'eye': 'auto',
+    }
+
+    with pytest.raises(AttributeError):
+        dataset.detect_events(**detect_event_kwargs)
+
+
 @pytest.mark.parametrize(
     'events_init, events_expected',
     [
@@ -766,3 +891,13 @@ def test_check_experiment():
     dataset = Dataset('data')
     with pytest.raises(AttributeError):
         dataset._check_experiment()
+
+
+@pytest.mark.parametrize('dataset_configuration', ['ToyBino'], indirect=['dataset_configuration'])
+def test_velocity_columns(dataset_configuration):
+    dataset = Dataset(**dataset_configuration['init_kwargs'])
+    dataset.load(preprocessed=True)
+
+    expected_velocity_columns = ['x_left_vel', 'y_left_vel', 'x_right_vel', 'y_right_vel']
+    case = unittest.TestCase()
+    case.assertCountEqual(dataset.velocity_columns, expected_velocity_columns)
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']'."""