Repository: aeye-lab/pymovements
Pull request #1149 (2025-05-12 10:32:12)

feat: add `exclude_none` argument to `to_dict()` methods
## Description

Please include a clear and concise description of the change and which issue is fixed.
Please also include excepted improvements, relevant motivation and context.
List any dependencies that are required for this change.

- Added __bool_ methods for the following classes : Experiment, EyeTracker and Screen
    - __bool__() returns False if every attributes in the class are evaluated as False ([], {}, False, None)
- Added to_dict() methods for EyeTracker and Screen
- Updated to_dict() methods to add an `exclude_none = True` parameter
    - if `exclude_none` is True, the returned dictionary won't contains fields whose values are evaluated to False ([], {}, False, None)
- Added tests for each implementations

## Implemented changes

Insert a description of the changes implemented in the pull request.

- [x] Added __bool_ methods for the following classes : Experiment, EyeTracker and Screen
- [x] Added to_dict() methods for EyeTracker and Screen
- [x] Updated to_dict() methods to add an `exclude_none = True` parameter

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes.
Provide instructions so we can reproduce.
Please also list any relevant details for your test configuration

- [x] tests/unit/gaze/experiment_test.py
- [x] tests/unit/gaze/screen_test.py
- [x] tests/unit/dataset/dataset_definition_test.py
- [x] tests/unit/dataset/eyetracker_test.py

## Type of change
- New functionality

#### related issues:
- #1091 

## 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/1149?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 `81.57895%` with `7 lines` in your changes missing coverage. Please review.
> Project coverage is 99.82%. Comparing base [(`028bdf6`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/028bdf608c64f1ddbac25632f34f20c84089eb52?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`0373cd2`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/0373cd25dd33af8f408c1355463c8ff4d85897ab?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

| [Files with missing lines](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?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/dataset\_definition.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&filepath=src%2Fpymovements%2Fdataset%2Fdataset_definition.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldF9kZWZpbml0aW9uLnB5) | 55.55% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |
| [src/pymovements/gaze/experiment.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&filepath=src%2Fpymovements%2Fgaze%2Fexperiment.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZXhwZXJpbWVudC5weQ==) | 85.71% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |
| [src/pymovements/gaze/eyetracker.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&filepath=src%2Fpymovements%2Fgaze%2Feyetracker.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZXlldHJhY2tlci5weQ==) | 90.90% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |
| [src/pymovements/gaze/screen.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&filepath=src%2Fpymovements%2Fgaze%2Fscreen.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvc2NyZWVuLnB5) | 90.90% | [0 Missing and 1 partial :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) |

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


```diff
@@             Coverage Diff             @@
##              main    #1149      +/-   ##
===========================================
- Coverage   100.00%   99.82%   -0.18%     
===========================================
  Files           90       90              
  Lines         3950     3982      +32     
  Branches       695      707      +12     
===========================================
+ Hits          3950     3975      +25     
- Misses           0        2       +2     
- Partials         0        5       +5     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/1149?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>
dkrako: As a suggestion but no strict requirement I think the duplicated logic could be refactored by using the `dict_factory` argument of the `asdict()` method.

you could probably do something like this to reduce complexity:

```python
def asdict_factory(
    exclude_private: bool = True,
    exclude_none: bool = True,
):
    def is_included(field: str, value: Any) -> bool:
        if exclude_private and field.startswith('_'):
            return False

        if isinstance(value, (bool, int, float)):
            return True

        if exclude_none and not value:
            return False

        return True

    def dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
        return {
            field: value for field, value in data
            if is_included(field, value)
        }

    return dict_factory
```
(implemented for example in `src/pymovements/_utils/_dataclasses.py`

This could then be used in all the `to_dict()` methods like this:

```python
dict_factory = asdict_factory(exclude_private=exclude_private, exclude_none=exclude_none)
return asdict(self, dict_factory=dict_factory)
```

If I'm correct then no further special handling in the `to_dict()` would be necessary this way and it's easy extendable.
Feel free to try this out but feel no obligation. This can also be done in a future refactor.
dkrako: Can you also add the `exclude_none` argument to `DatasetDefinition.to_yaml()`? That would be great!
dkrako: > > Can you also add the `exclude_none` argument to `DatasetDefinition.to_yaml()`? That would be great!
> 
> Done (I think)

Great! For the PR to be merged all the checks are required to pass. Please make sure to adjust your code accordingly.
pufferss: > > > Can you also add the `exclude_none` argument to `DatasetDefinition.to_yaml()`? That would be great!
> > 
> > 
> > Done (I think)
> 
> Great! For the PR to be merged all the checks are required to pass. Please make sure to adjust your code accordingly.

working on it 👍 
dkrako: > I quite don't understand the exclude_private arg, here's what i think: it's supposed to remove from the returned dict the fields begining by '_', but i didn't see any 'private' attributes... What am I missing ?

You're right, currently there are not private attributes in our dataclasses.

The only place where this argument is used is in this test:
https://github.com/aeye-lab/pymovements/blob/028bdf608c64f1ddbac25632f34f20c84089eb52/tests/unit/dataset/dataset_definition_test.py#L226-L231

Nevertheless this feature is needed and was recently introduced for #1064 which needs to exclude private attributes (will be merged soon).
dkrako: > I quite don't understand the exclude_private arg, here's what i think: it's supposed to remove from the returned dict the fields begining by '_', but i didn't see any 'private' attributes... What am I missing ?

But good catch! This probably means that forwarding `exclude_private` to `Experiment.to_dict()` isn't really needed currently.
pufferss: > > I quite don't understand the exclude_private arg, here's what i think: it's supposed to remove from the returned dict the fields begining by '_', but i didn't see any 'private' attributes... What am I missing ?
> 
> But good catch! This probably means that forwarding `exclude_private` to `Experiment.to_dict()` isn't really needed currently.

but 

> > I quite don't understand the exclude_private arg, here's what i think: it's supposed to remove from the returned dict the fields begining by '_', but i didn't see any 'private' attributes... What am I missing ?
> 
> But good catch! This probably means that forwarding `exclude_private` to `Experiment.to_dict()` isn't really needed currently.

Oh, I understand now, so, should I test that exclude_private arg in order to pass the codecov tests ?
dkrako: > > > I quite don't understand the exclude_private arg, here's what i think: it's supposed to remove from the returned dict the fields begining by '_', but i didn't see any 'private' attributes... What am I missing ?
> > 
> > 
> > But good catch! This probably means that forwarding `exclude_private` to `Experiment.to_dict()` isn't really needed currently.
> 
> but
> 
> > > I quite don't understand the exclude_private arg, here's what i think: it's supposed to remove from the returned dict the fields begining by '_', but i didn't see any 'private' attributes... What am I missing ?
> > 
> > 
> > But good catch! This probably means that forwarding `exclude_private` to `Experiment.to_dict()` isn't really needed currently.
> 
> Oh, I understand now, so, should I test that exclude_private arg in order to pass the codecov tests ?

You can decide yourself if you want to remove `exclude_private` in `Experiment.to_dict()` or keep it (then you should add a test). `DatasetDefinition.to_dict(exclude_private=False)` is already tested.
pufferss: Some of the failed tests (minimum requirements) also fails when i run pytest on the main branch, so I don't think it's because of my code ?
pufferss: all tests passed 🎉
pufferss: > Great job! 👍
> 
> One last thing: can you split and parametrize your tests? they are testing multiple things currently. I rewrote `test_experiment_bool` to mark an expected failure that will be resolved with #1148.
> 
> Please do something in this manner for your remaining tests.

Ok no problem
pufferss: DatasetDefinition also have a default value that is not None (like origin in Screen and Experiment), it's the time_unit attributes that is 'ms' by default, I don't know if you'll include it in #1148 , if not, DatasetDefinition.to_dict() will never return an empty dict when it's called like this :
```
datasetdef = DatasetDefinition()
datasetdef.to_dict()
```

Is it the expected behavior ?
dkrako: > DatasetDefinition also have a default value that is not None (like origin in Screen and Experiment), it's the time_unit attributes that is 'ms' by default, I don't know if you'll include it in #1148 , if not, DatasetDefinition.to_dict() will never return an empty dict when it's called like this :
> 
> ```
> datasetdef = DatasetDefinition()
> datasetdef.to_dict()
> ```
> 
> Is it the expected behavior ?

Good catch, you're right. We will resolve this after #1148 by also changing the default to `None`.
pufferss: > Great! Thank you so much for your initiative and taking care of all the details.

Thank you for your reactivity and your amiability, it was my first contribution ever and it was a pleasure to work on this ! 

Patch:
diff --git a/src/pymovements/dataset/dataset_definition.py b/src/pymovements/dataset/dataset_definition.py
index df50c24..d357083 100644
--- a/src/pymovements/dataset/dataset_definition.py
+++ b/src/pymovements/dataset/dataset_definition.py
@@ -201,13 +201,17 @@ class DatasetDefinition:
         # Initialize DatasetDefinition with YAML data
         return DatasetDefinition(**data)
 
-    def to_dict(self, exclude_private: bool = True) -> dict[str, Any]:
+    def to_dict(self, exclude_private: bool = True, exclude_none: bool = True) -> dict[str, Any]:
         """Return dictionary representation.
 
         Parameters
         ----------
         exclude_private: bool
-            Exclude attributes that start with `_`.
+            Exclude attributes that start with ``_``.
+        exclude_none: bool
+            Exclude attributes that are either ``None`` or that are objects that evaluate to
+            ``False`` (e.g., ``[]``, ``{}``, ``EyeTracker()``). Attributes of type ``bool``,
+            ``int``, and ``float`` are not excluded.
 
         Returns
         -------
@@ -222,11 +226,26 @@ class DatasetDefinition:
                 if key.startswith('_'):
                     del data[key]
 
-        data['experiment'] = data['experiment'].to_dict()
+        # Delete fields that evaluate to False (False, None, [], {})
+        if exclude_none:
+            if not self.experiment:
+                del data['experiment']
+            else:
+                data['experiment'] = data['experiment'].to_dict(exclude_none=exclude_none)
+
+            for key, value in list(data.items()):
+                if not isinstance(value, (bool, int, float)) and not value:
+                    del data[key]
+        else:
+            data['experiment'] = data['experiment'].to_dict(exclude_none=exclude_none)
 
         return data
 
-    def to_yaml(self, path: str | Path, exclude_private: bool = True) -> None:
+    def to_yaml(
+        self, path: str | Path,
+        exclude_private: bool = True,
+        exclude_none: bool = True,
+    ) -> None:
         """Save a dataset definition to a YAML file.
 
         Parameters
@@ -234,9 +253,13 @@ class DatasetDefinition:
         path: str | Path
             Path where to save the YAML file to.
         exclude_private: bool
-            Exclude attributes that start with `_`.
+            Exclude attributes that start with ``_``.
+        exclude_none: bool
+            Exclude attributes that are either ``None`` or that are objects that evaluate to
+            ``False`` (e.g., ``[]``, ``{}``, ``EyeTracker()``). Attributes of type ``bool``,
+            ``int``, and ``float`` are not excluded.
         """
-        data = self.to_dict(exclude_private=exclude_private)
+        data = self.to_dict(exclude_private=exclude_private, exclude_none=exclude_none)
 
         data = substitute_types(data)
 
diff --git a/src/pymovements/gaze/experiment.py b/src/pymovements/gaze/experiment.py
index b2bd449..8f65f3a 100644
--- a/src/pymovements/gaze/experiment.py
+++ b/src/pymovements/gaze/experiment.py
@@ -21,7 +21,6 @@
 from __future__ import annotations
 
 from copy import deepcopy
-from dataclasses import asdict
 from typing import Any
 
 import numpy as np
@@ -282,19 +281,39 @@ class Experiment:
         """Compare equality to other Experiment."""
         return self.screen == other.screen and self.eyetracker == other.eyetracker
 
-    def to_dict(self) -> dict[str, Any | dict[str, str | float | None]]:
+    def to_dict(
+        self, exclude_none: bool = True,
+    ) -> dict[str, Any | dict[str, str | float | None]]:
         """Convert the experiment instance into a dictionary.
 
+        Parameters
+        ----------
+        exclude_none: bool
+            Exclude attributes that are either ``None`` or that are objects that evaluate to
+            ``False`` (e.g., ``[]``, ``{}``, ``EyeTracker()``). Attributes of type ``bool``,
+            ``int``, and ``float`` are not excluded.
+
         Returns
         -------
         dict[str, Any | dict[str, str | float | None]]
             Experiment as dictionary.
         """
         _dict: dict[str, dict[str, str | float | None]] = {}
-        _dict['screen'] = asdict(self.screen)
-        _dict['eyetracker'] = asdict(self.eyetracker)
+        if exclude_none:
+            if self.screen:
+                _dict['screen'] = self.screen.to_dict(exclude_none=exclude_none)
+            if self.eyetracker:
+                _dict['eyetracker'] = self.eyetracker.to_dict(exclude_none=exclude_none)
+        else:
+            _dict['screen'] = self.screen.to_dict(exclude_none=False)
+            _dict['eyetracker'] = self.eyetracker.to_dict(exclude_none=False)
+
         return _dict
 
     def __str__(self: Experiment) -> str:
         """Return Experiment string."""
         return f'{type(self).__name__}(screen={self.screen}, eyetracker={self.eyetracker})'
+
+    def __bool__(self) -> bool:
+        """Return True if the experiment has data defined, else False."""
+        return not all(not value for value in self.__dict__.values())
diff --git a/src/pymovements/gaze/eyetracker.py b/src/pymovements/gaze/eyetracker.py
index bb9d7b6..6fb3ede 100644
--- a/src/pymovements/gaze/eyetracker.py
+++ b/src/pymovements/gaze/eyetracker.py
@@ -20,7 +20,9 @@
 """Holds the EyeTracker class."""
 from __future__ import annotations
 
+from dataclasses import asdict
 from dataclasses import dataclass
+from typing import Any
 
 from pymovements._utils import _checks
 from pymovements._utils._html import repr_html
@@ -77,3 +79,32 @@ class EyeTracker:
         """Check that the sampling rate is a positive number."""
         if self.sampling_rate is not None:
             _checks.check_is_greater_than_zero(sampling_rate=self.sampling_rate)
+
+    def to_dict(self, exclude_none: bool = True) -> dict[str, Any]:
+        """Convert the EyeTracker instance into a dictionary.
+
+        Parameters
+        ----------
+        exclude_none: bool
+            Exclude attributes that are either ``None`` or that are objects that evaluate to
+            ``False`` (e.g., ``[]``, ``{}``, ``EyeTracker()``). Attributes of type ``bool``,
+            ``int``, and ``float`` are not excluded.
+
+        Returns
+        -------
+        dict[str, Any]
+            EyeTracker as dictionary.
+        """
+        _dict = asdict(self)
+
+        # Delete fields that evaluate to False (False, None, [], {})
+        if exclude_none:
+            for key, value in list(_dict.items()):
+                if not isinstance(value, (bool, int, float)) and not value:
+                    del _dict[key]
+
+        return _dict
+
+    def __bool__(self) -> bool:
+        """Return True if the eyetracker has data defined, else False."""
+        return not all(not value for value in self.__dict__.values())
diff --git a/src/pymovements/gaze/screen.py b/src/pymovements/gaze/screen.py
index aa7f00e..d6e2232 100644
--- a/src/pymovements/gaze/screen.py
+++ b/src/pymovements/gaze/screen.py
@@ -20,7 +20,9 @@
 """Provides the Screen class."""
 from __future__ import annotations
 
+from dataclasses import asdict
 from dataclasses import dataclass
+from typing import Any
 
 import numpy as np
 
@@ -282,3 +284,32 @@ class Screen:
         _checks.check_is_not_none(**{key: value})
         assert isinstance(value, (int, float))
         _checks.check_is_greater_than_zero(**{key: value})
+
+    def to_dict(self, exclude_none: bool = True) -> dict[str, Any]:
+        """Convert the Screen instance into a dictionary.
+
+        Parameters
+        ----------
+        exclude_none: bool
+            Exclude attributes that are either ``None`` or that are objects that evaluate to
+            ``False`` (e.g., ``[]``, ``{}``, ``EyeTracker()``). Attributes of type ``bool``,
+            ``int``, and ``float`` are not excluded.
+
+        Returns
+        -------
+        dict[str, Any]
+            Screen as dictionary.
+        """
+        _dict = asdict(self)
+
+        # Delete fields that evaluate to False (False, None, [], {})
+        if exclude_none:
+            for key, value in list(_dict.items()):
+                if not isinstance(value, (bool, int, float)) and not value:
+                    del _dict[key]
+
+        return _dict
+
+    def __bool__(self) -> bool:
+        """Return True if the screen has data defined, else False."""
+        return not all(not value for value in self.__dict__.values())


Test patch:
diff --git a/tests/unit/dataset/dataset_definition_test.py b/tests/unit/dataset/dataset_definition_test.py
index e501bb5..9f2400f 100644
--- a/tests/unit/dataset/dataset_definition_test.py
+++ b/tests/unit/dataset/dataset_definition_test.py
@@ -173,7 +173,7 @@ def test_dataset_definition_is_equal(init_kwargs):
     ],
 )
 def test_dataset_definition_to_dict_expected(definition, expected_dict):
-    assert definition.to_dict() == expected_dict
+    assert definition.to_dict(exclude_none=False) == expected_dict
 
 
 @pytest.mark.parametrize(
@@ -277,7 +277,7 @@ def test_dataset_definition_to_dict_exclude_private_expected(exclude_private, ex
 
     definition = MyDatasetDefinition()
 
-    assert definition.to_dict(exclude_private=exclude_private) == expected_dict
+    assert definition.to_dict(exclude_private=exclude_private, exclude_none=False) == expected_dict
 
 
 @pytest.mark.parametrize(
@@ -331,7 +331,7 @@ def test_dataset_definition_to_yaml_equal_dicts(definition, tmp_path):
 def test_write_yaml_already_existing_dataset_definition_w_tuple_screen(tmp_path):
     tmp_file = tmp_path / 'tmp.yaml'
     definition = DatasetLibrary.get('ToyDatasetEyeLink')
-    definition.to_yaml(tmp_file)
+    definition.to_yaml(tmp_file, exclude_none=False)
 
     with open(tmp_file, encoding='utf-8') as f:
         yaml.safe_load(f)
@@ -365,3 +365,169 @@ def test_check_equality_of_load_from_yaml_and_load_from_dictionary_dump(tmp_path
     )
 
     assert yaml_definition == expected_definition
+
+
+@pytest.mark.parametrize(
+    ('dataset_definition', 'exclude_none', 'expected_dict'),
+    [
+        pytest.param(
+            DatasetDefinition(),
+            True,
+            {
+                'name': '.',
+                'time_unit': 'ms',
+            },
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='true_default',
+        ),
+
+        pytest.param(
+            DatasetDefinition(experiment=Experiment(origin=None)),
+            True,
+            {
+                'name': '.',
+                'time_unit': 'ms',
+            },
+            id='true_experiment_origin_none',
+        ),
+
+        pytest.param(
+            DatasetDefinition(
+                distance_column='test',
+                extract={'test': True},
+                position_columns=['test', 'foo', 'bar'],
+            ),
+            True,
+            {
+                'name': '.',
+                'extract': {'test': True},
+                'time_unit': 'ms',
+                'position_columns': ['test', 'foo', 'bar'],
+                'distance_column': 'test',
+            },
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='true_str_dict_list',
+        ),
+
+        pytest.param(
+            DatasetDefinition(
+                experiment=Experiment(origin=None),
+                distance_column='test',
+                extract={'test': True},
+                position_columns=['test', 'foo', 'bar'],
+            ),
+            True,
+            {
+                'name': '.',
+                'extract': {'test': True},
+                'time_unit': 'ms',
+                'position_columns': ['test', 'foo', 'bar'],
+                'distance_column': 'test',
+            },
+            id='true_str_dict_list_experiment_origin_none',
+        ),
+
+        pytest.param(
+            DatasetDefinition(),
+            False,
+            {
+                'name': '.',
+                'long_name': None,
+                'has_files': {},
+                'mirrors': {},
+                'resources': {},
+                'experiment': None,
+                'extract': {},
+                'filename_format': {},
+                'filename_format_schema_overrides': {},
+                'custom_read_kwargs': {},
+                'column_map': {},
+                'trial_columns': None,
+                'time_column': None,
+                'time_unit': 'ms',
+                'pixel_columns': None,
+                'position_columns': None,
+                'velocity_columns': None,
+                'acceleration_columns': None,
+                'distance_column': None,
+            },
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='false_default',
+        ),
+
+        pytest.param(
+            DatasetDefinition(experiment=None),
+            False,
+            {
+                'name': '.',
+                'long_name': None,
+                'has_files': {},
+                'mirrors': {},
+                'resources': {},
+                'experiment': None,
+                'extract': {},
+                'filename_format': {},
+                'filename_format_schema_overrides': {},
+                'custom_read_kwargs': {},
+                'column_map': {},
+                'trial_columns': None,
+                'time_column': None,
+                'time_unit': 'ms',
+                'pixel_columns': None,
+                'position_columns': None,
+                'velocity_columns': None,
+                'acceleration_columns': None,
+                'distance_column': None,
+            },
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='false_experiment_none',
+        ),
+
+        pytest.param(
+            DatasetDefinition(experiment=Experiment(origin=None)),
+            False,
+            {
+                'name': '.',
+                'long_name': None,
+                'has_files': {},
+                'mirrors': {},
+                'resources': {},
+                'experiment': {
+                    'eyetracker': {
+                        'sampling_rate': None,
+                        'vendor': None,
+                        'model': None,
+                        'version': None,
+                        'mount': None,
+                        'left': None,
+                        'right': None,
+                    },
+                    'screen': {
+                        'height_cm': None,
+                        'width_cm': None,
+                        'height_px': None,
+                        'width_px': None,
+                        'distance_cm': None,
+                        'origin': None,
+                    },
+                },
+                'extract': {},
+                'filename_format': {},
+                'filename_format_schema_overrides': {},
+                'custom_read_kwargs': {},
+                'column_map': {},
+                'trial_columns': None,
+                'time_column': None,
+                'time_unit': 'ms',
+                'pixel_columns': None,
+                'position_columns': None,
+                'velocity_columns': None,
+                'acceleration_columns': None,
+                'distance_column': None,
+            },
+            id='false_experiment_origin_none',
+        ),
+    ],
+)
+def test_dataset_to_dict_exclude_none(dataset_definition, exclude_none, expected_dict):
+    assert dataset_definition.to_dict(exclude_none=exclude_none) == expected_dict
diff --git a/tests/unit/gaze/experiment_test.py b/tests/unit/gaze/experiment_test.py
index 9c2fd50..482c539 100644
--- a/tests/unit/gaze/experiment_test.py
+++ b/tests/unit/gaze/experiment_test.py
@@ -111,3 +111,150 @@ def test_sampling_rate_equality(experiment1, experiment2):
 def test_experiment_from_dict(dictionary, expected_experiment):
     experiment = Experiment.from_dict(dictionary)
     assert experiment == expected_experiment
+
+
+@pytest.mark.parametrize(
+    ('experiment', 'exclude_none', 'expected_dict'),
+    [
+        pytest.param(
+            Experiment(),
+            True,
+            {},
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='true_default',
+        ),
+        pytest.param(
+            Experiment(origin=None),
+            True,
+            {},
+            id='true_origin_none',
+        ),
+        pytest.param(
+            Experiment(sampling_rate=18.5),
+            True,
+            {'eyetracker': {'sampling_rate': 18.5}},
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='true_sampling_rate_18.5',
+        ),
+        pytest.param(
+            Experiment(sampling_rate=18.5, origin=None),
+            True,
+            {'eyetracker': {'sampling_rate': 18.5}},
+            id='true_sampling_rate_18.5_origin_none',
+        ),
+        pytest.param(
+            Experiment(screen=Screen(height_px=1080), eyetracker=EyeTracker(left=True)),
+            True,
+            {
+                'screen': {
+                    'height_px': 1080,
+                },
+                'eyetracker': {
+                    'left': True,
+                },
+            },
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='true_screen_eyetracker',
+        ),
+        pytest.param(
+            Experiment(
+                screen=Screen(height_px=1080, origin=None),
+                eyetracker=EyeTracker(left=True),
+            ),
+            True,
+            {
+                'screen': {
+                    'height_px': 1080,
+                },
+                'eyetracker': {
+                    'left': True,
+                },
+            },
+            id='true_screen_origin_none_eyetracker',
+        ),
+        pytest.param(
+            Experiment(),
+            False,
+            {
+                'screen': {
+                    'width_px': None,
+                    'height_px': None,
+                    'width_cm': None,
+                    'height_cm': None,
+                    'distance_cm': None,
+                    'origin': None,
+                },
+                'eyetracker': {
+                    'sampling_rate': None,
+                    'vendor': None,
+                    'model': None,
+                    'version': None,
+                    'mount': None,
+                    'left': None,
+                    'right': None,
+                },
+            },
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='false_default',
+        ),
+        pytest.param(
+            Experiment(origin=None),
+            False,
+            {
+                'screen': {
+                    'width_px': None,
+                    'height_px': None,
+                    'width_cm': None,
+                    'height_cm': None,
+                    'distance_cm': None,
+                    'origin': None,
+                },
+                'eyetracker': {
+                    'sampling_rate': None,
+                    'vendor': None,
+                    'model': None,
+                    'version': None,
+                    'mount': None,
+                    'left': None,
+                    'right': None,
+                },
+            },
+            id='false_all_none',
+        ),
+    ],
+)
+def test_experiment_to_dict_exclude_none(experiment, exclude_none, expected_dict):
+    assert experiment.to_dict(exclude_none=exclude_none) == expected_dict
+
+
+@pytest.mark.parametrize(
+    ('experiment', 'expected_bool'),
+    [
+        pytest.param(
+            Experiment(),
+            False,
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='default',
+        ),
+
+        pytest.param(
+            Experiment(origin=None),
+            False,
+            id='origin_none',
+        ),
+
+        pytest.param(
+            Experiment(origin='center'),
+            True,
+            id='origin_center',
+        ),
+
+        pytest.param(
+            Experiment(distance_cm=60),
+            True,
+            id='distance_60',
+        ),
+    ],
+)
+def test_experiment_bool(experiment, expected_bool):
+    assert bool(experiment) == expected_bool
diff --git a/tests/unit/gaze/eyetracker_test.py b/tests/unit/gaze/eyetracker_test.py
index b20f638..a8abe3f 100644
--- a/tests/unit/gaze/eyetracker_test.py
+++ b/tests/unit/gaze/eyetracker_test.py
@@ -43,3 +43,62 @@ def test_eyetracker_without_sampling_rate():
         None, False, True, 'EyeLink 1000 Plus',
         '1.5.3', 'Arm Mount / Monocular / Remote',
     )
+
+
+@pytest.mark.parametrize(
+    ('eyetracker', 'expected_dict', 'exclude_none'),
+    [
+        pytest.param(
+            EyeTracker(),
+            {},
+            True,
+            id='default',
+        ),
+        pytest.param(
+            EyeTracker(right=True, model='test'),
+            {'right': True, 'model': 'test'},
+            True,
+            id='right_model',
+        ),
+        pytest.param(
+            EyeTracker(),
+            {
+                'sampling_rate': None,
+                'left': None,
+                'right': None,
+                'model': None,
+                'version': None,
+                'vendor': None,
+                'mount': None,
+            },
+            False,
+            id='all_none',
+        ),
+    ],
+)
+def test_eyetracker_to_dict_exclude_none(eyetracker, expected_dict, exclude_none):
+    assert eyetracker.to_dict(exclude_none=exclude_none) == expected_dict
+
+
+@pytest.mark.parametrize(
+    ('eyetracker', 'expected_bool'),
+    [
+        pytest.param(
+            EyeTracker(),
+            False,
+            id='default',
+        ),
+        pytest.param(
+            EyeTracker(mount='test'),
+            True,
+            id='mount',
+        ),
+        pytest.param(
+            EyeTracker(sampling_rate=2.0),
+            True,
+            id='sampling_rate_2',
+        ),
+    ],
+)
+def test_eyetracker_bool_all_none(eyetracker, expected_bool):
+    assert bool(eyetracker) == expected_bool
diff --git a/tests/unit/gaze/screen_test.py b/tests/unit/gaze/screen_test.py
index 4efb2ec..f5f365c 100644
--- a/tests/unit/gaze/screen_test.py
+++ b/tests/unit/gaze/screen_test.py
@@ -103,3 +103,69 @@ def test_pix2deg_without_attributes(missing_attribute, exception, exception_msg)
 def test_screen_init_without_attributes():
     screen = pm.Screen()
     assert isinstance(screen, pm.Screen)
+
+
+@pytest.mark.parametrize(
+    ('screen', 'expected_dict', 'exclude_none'),
+    [
+        pytest.param(
+            pm.Screen(),
+            {},
+            True,
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='default',
+        ),
+        pytest.param(
+            pm.Screen(height_px=150, origin='test'),
+            {'height_px': 150, 'origin': 'test'},
+            True,
+            id='height_px_origin',
+        ),
+        pytest.param(
+            pm.Screen(),
+            {
+                'width_px': None,
+                'height_px': None,
+                'width_cm': None,
+                'height_cm': None,
+                'distance_cm': None,
+                'origin': None,
+            },
+            False,
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='all_none',
+        ),
+    ],
+)
+def test_screen_to_dict_exclude_none(screen, expected_dict, exclude_none):
+    assert screen.to_dict(exclude_none=exclude_none) == expected_dict
+
+
+@pytest.mark.parametrize(
+    ('screen', 'expected_bool'),
+    [
+        pytest.param(
+            pm.Screen(),
+            False,
+            marks=pytest.mark.xfail(reason='#1148'),
+            id='default',
+        ),
+        pytest.param(
+            pm.Screen(origin=None),
+            False,
+            id='origin_none',
+        ),
+        pytest.param(
+            pm.Screen(height_cm=10.0),
+            True,
+            id='height_cm_10',
+        ),
+        pytest.param(
+            pm.Screen(width_px=300),
+            True,
+            id='width_px_300',
+        ),
+    ],
+)
+def test_screen_bool_all_none(screen, expected_bool):
+    assert bool(screen) == expected_bool