Repository: aeye-lab/pymovements (python)

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

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

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

## Description of a solution

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

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


Pull request:
depr: deprecate `utils.filters`
## :warning: Deprecation

`utils.filters` is deprecated and will be removed in pymovements v0.26.0

## Description

Deprecate `utils.filters` as none of the included are intended for the end-user.

The following warnings are printed:

```
DeprecationWarning: Call to deprecated function (or staticmethod) filter_candidates_remove_nans. (This function will be removed in v0.26.0.) -- Deprecated since version v0.21.0.
```

```
DeprecationWarning: Call to deprecated function (or staticmethod) events_split_nans. (This function will be removed in v0.26.0.) -- Deprecated since version v0.21.0.
```


partly resolves #999

## Implemented changes

- [x] moved `utils/filters.py` to `events/_utils/_paths.py` and adjusted to new location
- [x] use `events._utils.filters` in remaining source
- [x] created deprecated function in `utils/filters.py`
- [x] moved `tests/unit/utils/filters_test.py` to `tests/unit/events/_utils/_filters_test.py`
- [x] add test that functions are correctly deprecated
- [x] add test that functions are removed in planned version


Patch:
diff --git a/src/pymovements/events/_utils/__init__.py b/src/pymovements/events/_utils/__init__.py
new file mode 100644
index 0000000..6786db2
--- /dev/null
+++ b/src/pymovements/events/_utils/__init__.py
@@ -0,0 +1,19 @@
+# Copyright (c) 2025 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# 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.
diff --git a/src/pymovements/events/_utils/_filters.py b/src/pymovements/events/_utils/_filters.py
new file mode 100644
index 0000000..cf2f147
--- /dev/null
+++ b/src/pymovements/events/_utils/_filters.py
@@ -0,0 +1,99 @@
+# Copyright (c) 2023-2025 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Provides filter specific funtions."""
+from __future__ import annotations
+
+import numpy as np
+
+from pymovements.gaze.transforms_numpy import consecutive
+
+
+def filter_candidates_remove_nans(
+        candidates: list[np.ndarray],
+        values: np.ndarray,
+) -> list[np.ndarray]:
+    """Filter a list of candidates for an event-detection algorithm.
+
+    Removes leading and ending np.nans for all candidates in candidates
+
+    Parameters
+    ----------
+    candidates: list[np.ndarray]
+        List of candidates; each candidate consists of a list of indices
+    values: np.ndarray
+        shape (N, 1) or shape (N, 2)
+        Corresponding continuous 1D/2D values time series.
+
+    Returns
+    -------
+    list[np.ndarray]
+        Returns a filtered list of candidates.
+    """
+    values = np.array(values)
+    return_candidates = []
+    for candidate in candidates:
+        if len(candidate) == 0:
+            continue
+        cand_values = values[np.array(candidate)]
+        start_id = 0
+        while np.sum(np.isnan(cand_values[start_id, :])) > 0:
+            start_id += 1
+        end_id = len(cand_values) - 1
+        while np.sum(np.isnan(cand_values[end_id, :])) > 0:
+            end_id -= 1
+        cur_candidate = list(candidate[start_id:end_id + 1])
+        return_candidates.append(np.array(cur_candidate))
+    return return_candidates
+
+
+def events_split_nans(
+        candidates: list[np.ndarray],
+        values: np.ndarray,
+) -> list[np.ndarray]:
+    """Filter a list of candidates for an event-detection algorithm.
+
+    Splits events if np.nans are within an event
+
+    Parameters
+    ----------
+    candidates: list[np.ndarray]
+        List of candidates; each candidate consists of a list of indices
+    values: np.ndarray
+        shape (N, 1) or shape (N, 2)
+        Corresponding continuous 1D/2D values time series.
+
+    Returns
+    -------
+    list[np.ndarray]
+        Returns a filtered list of candidates.
+    """
+    values = np.array(values)
+    return_candidates = []
+    for candidate in candidates:
+        if len(candidate) == 0:
+            continue
+        cur_values = values[np.array(candidate)]
+        nan_candidates = consecutive(arr=np.where(~np.isnan(np.sum(cur_values, axis=1)))[0])
+        cand_list = [
+            np.array(candidate[candidate_indices[0]:candidate_indices[-1] + 1])
+            for candidate_indices in nan_candidates
+        ]
+        return_candidates += cand_list
+    return return_candidates
diff --git a/src/pymovements/events/detection/_idt.py b/src/pymovements/events/detection/_idt.py
index 82916aa..a1dfe83 100644
--- a/src/pymovements/events/detection/_idt.py
+++ b/src/pymovements/events/detection/_idt.py
@@ -22,11 +22,11 @@ from __future__ import annotations
 
 import numpy as np
 
+from pymovements.events._utils._filters import events_split_nans
+from pymovements.events._utils._filters import filter_candidates_remove_nans
 from pymovements.events.detection._library import register_event_detection
 from pymovements.events.frame import EventDataFrame
 from pymovements.utils import checks
-from pymovements.utils.filters import events_split_nans
-from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 def dispersion(positions: list[list[float]] | np.ndarray) -> float:
diff --git a/src/pymovements/events/detection/_ivt.py b/src/pymovements/events/detection/_ivt.py
index 1a82ff9..030720b 100644
--- a/src/pymovements/events/detection/_ivt.py
+++ b/src/pymovements/events/detection/_ivt.py
@@ -22,12 +22,12 @@ from __future__ import annotations
 
 import numpy as np
 
+from pymovements.events._utils._filters import filter_candidates_remove_nans
 from pymovements.events.detection._library import register_event_detection
 from pymovements.events.frame import EventDataFrame
 from pymovements.gaze.transforms_numpy import consecutive
 from pymovements.gaze.transforms_numpy import norm
 from pymovements.utils import checks
-from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 @register_event_detection
diff --git a/src/pymovements/events/detection/_microsaccades.py b/src/pymovements/events/detection/_microsaccades.py
index 5cb5a2c..c192cb1 100644
--- a/src/pymovements/events/detection/_microsaccades.py
+++ b/src/pymovements/events/detection/_microsaccades.py
@@ -24,11 +24,11 @@ from collections.abc import Sized
 
 import numpy as np
 
+from pymovements.events._utils._filters import filter_candidates_remove_nans
 from pymovements.events.detection._library import register_event_detection
 from pymovements.events.frame import EventDataFrame
 from pymovements.gaze.transforms_numpy import consecutive
 from pymovements.utils import checks
-from pymovements.utils.filters import filter_candidates_remove_nans
 
 
 @register_event_detection
diff --git a/src/pymovements/utils/filters.py b/src/pymovements/utils/filters.py
index cf2f147..7fcd132 100644
--- a/src/pymovements/utils/filters.py
+++ b/src/pymovements/utils/filters.py
@@ -17,14 +17,25 @@
 # 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.
-"""Provides filter specific funtions."""
+"""Provides filter specific funtions.
+
+.. deprecated:: v0.21.1
+   This module will be removed in v0.26.0.
+"""
 from __future__ import annotations
 
 import numpy as np
+from deprecated.sphinx import deprecated
 
-from pymovements.gaze.transforms_numpy import consecutive
+from pymovements.events._utils._filters import events_split_nans as _events_split_nans
+from pymovements.events._utils._filters import filter_candidates_remove_nans \
+    as _filter_candidates_remove_nans
 
 
+@deprecated(
+    reason='This function will be removed in v0.26.0.',
+    version='v0.21.1',
+)
 def filter_candidates_remove_nans(
         candidates: list[np.ndarray],
         values: np.ndarray,
@@ -33,6 +44,9 @@ def filter_candidates_remove_nans(
 
     Removes leading and ending np.nans for all candidates in candidates
 
+    .. deprecated:: v0.21.1
+       This module will be removed in v0.26.0.
+
     Parameters
     ----------
     candidates: list[np.ndarray]
@@ -46,23 +60,16 @@ def filter_candidates_remove_nans(
     list[np.ndarray]
         Returns a filtered list of candidates.
     """
-    values = np.array(values)
-    return_candidates = []
-    for candidate in candidates:
-        if len(candidate) == 0:
-            continue
-        cand_values = values[np.array(candidate)]
-        start_id = 0
-        while np.sum(np.isnan(cand_values[start_id, :])) > 0:
-            start_id += 1
-        end_id = len(cand_values) - 1
-        while np.sum(np.isnan(cand_values[end_id, :])) > 0:
-            end_id -= 1
-        cur_candidate = list(candidate[start_id:end_id + 1])
-        return_candidates.append(np.array(cur_candidate))
-    return return_candidates
+    return _filter_candidates_remove_nans(
+        candidates=candidates,
+        values=values,
+    )
 
 
+@deprecated(
+    reason='This function will be removed in v0.26.0.',
+    version='v0.21.1',
+)
 def events_split_nans(
         candidates: list[np.ndarray],
         values: np.ndarray,
@@ -71,6 +78,9 @@ def events_split_nans(
 
     Splits events if np.nans are within an event
 
+    .. deprecated:: v0.21.1
+       This module will be removed in v0.26.0.
+
     Parameters
     ----------
     candidates: list[np.ndarray]
@@ -84,16 +94,7 @@ def events_split_nans(
     list[np.ndarray]
         Returns a filtered list of candidates.
     """
-    values = np.array(values)
-    return_candidates = []
-    for candidate in candidates:
-        if len(candidate) == 0:
-            continue
-        cur_values = values[np.array(candidate)]
-        nan_candidates = consecutive(arr=np.where(~np.isnan(np.sum(cur_values, axis=1)))[0])
-        cand_list = [
-            np.array(candidate[candidate_indices[0]:candidate_indices[-1] + 1])
-            for candidate_indices in nan_candidates
-        ]
-        return_candidates += cand_list
-    return return_candidates
+    return _events_split_nans(
+        candidates=candidates,
+        values=values,
+    )


Test patch:
diff --git a/tests/unit/events/_utils/_filters_test.py b/tests/unit/events/_utils/_filters_test.py
new file mode 100644
index 0000000..07b6459
--- /dev/null
+++ b/tests/unit/events/_utils/_filters_test.py
@@ -0,0 +1,91 @@
+# Copyright (c) 2023-2025 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Test pymovements filters."""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from pymovements.events._utils._filters import events_split_nans
+from pymovements.events._utils._filters import filter_candidates_remove_nans
+
+
+@pytest.mark.parametrize(
+    ('params', 'expected'),
+    [
+        pytest.param(
+            {
+                'candidates': [
+                    [0, 1, 2, 3, 4],
+                    [5, 6, 7, 8],
+                ],
+                'values': np.array([
+                    (np.nan, np.nan), (0, 0),
+                    (0, 0), (0, 0),
+                    (np.nan, np.nan),
+                    (np.nan, np.nan),
+                    (0, 0), (0, 0),
+                    (0, 0),
+                ]),
+            },
+            {'values_filter': [np.array([1, 2, 3]), np.array([6, 7, 8])]},
+            id='test_filters',
+        ),
+        pytest.param(
+            {
+                'candidates': [[0, 1, 2, 3, 4, 5, 6, 7]],
+                'values': np.array([
+                    (0, 0),
+                    (0, 0), (0, 0),
+                    (np.nan, np.nan),
+                    (np.nan, np.nan),
+                    (0, 0), (0, 0),
+                    (0, 0),
+                ]),
+            },
+            {'values_split': [np.array([0, 1, 2]), np.array([5, 6, 7])]},
+            id='test_events_split',
+        ),
+        pytest.param(
+            {'candidates': [[]], 'values': np.array([(0, 0)])},
+            {'values_filter': []},
+            id='test_no_candidates_in_array',
+        ),
+        pytest.param(
+            {'candidates': [[]], 'values': np.array([(np.nan, np.nan)])},
+            {'values_split': []},
+            id='test_no_candidates_in_array_nan',
+        ),
+    ],
+)
+def test_filters(params, expected):
+    if 'values_filter' in expected:
+        results = filter_candidates_remove_nans(
+            params['candidates'],
+            params['values'],
+        )
+        assert np.all(np.array(expected['values_filter']) == np.array(results))
+
+    if 'values_split' in expected:
+        results = events_split_nans(
+            params['candidates'],
+            params['values'],
+        )
+        assert np.all(np.array(expected['values_split']) == np.array(results))
diff --git a/tests/unit/utils/filters_test.py b/tests/unit/utils/filters_test.py
index 7ccaf81..808ad1d 100644
--- a/tests/unit/utils/filters_test.py
+++ b/tests/unit/utils/filters_test.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023-2025 The pymovements Project Authors
+# Copyright (c) 2025 The pymovements Project Authors
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to deal
@@ -17,75 +17,49 @@
 # 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 filters."""
-from __future__ import annotations
+"""Tests deprecated utils.filters."""
+import re
 
 import numpy as np
 import pytest
 
+from pymovements import __version__
 from pymovements.utils.filters import events_split_nans
 from pymovements.utils.filters import filter_candidates_remove_nans
 
 
-@pytest.mark.parametrize(
-    ('params', 'expected'),
-    [
-        pytest.param(
-            {
-                'candidates': [
-                    [0, 1, 2, 3, 4],
-                    [5, 6, 7, 8],
-                ],
-                'values': np.array([
-                    (np.nan, np.nan), (0, 0),
-                    (0, 0), (0, 0),
-                    (np.nan, np.nan),
-                    (np.nan, np.nan),
-                    (0, 0), (0, 0),
-                    (0, 0),
-                ]),
-            },
-            {'values_filter': [np.array([1, 2, 3]), np.array([6, 7, 8])]},
-            id='test_filters',
-        ),
-        pytest.param(
-            {
-                'candidates': [[0, 1, 2, 3, 4, 5, 6, 7]],
-                'values': np.array([
-                    (0, 0),
-                    (0, 0), (0, 0),
-                    (np.nan, np.nan),
-                    (np.nan, np.nan),
-                    (0, 0), (0, 0),
-                    (0, 0),
-                ]),
-            },
-            {'values_split': [np.array([0, 1, 2]), np.array([5, 6, 7])]},
-            id='test_events_split',
-        ),
-        pytest.param(
-            {'candidates': [[]], 'values': np.array([(0, 0)])},
-            {'values_filter': []},
-            id='test_no_candidates_in_array',
-        ),
-        pytest.param(
-            {'candidates': [[]], 'values': np.array([(np.nan, np.nan)])},
-            {'values_split': []},
-            id='test_no_candidates_in_array_nan',
-        ),
-    ],
-)
-def test_filters(params, expected):
-    if 'values_filter' in expected:
-        results = filter_candidates_remove_nans(
-            params['candidates'],
-            params['values'],
-        )
-        assert np.all(np.array(expected['values_filter']) == np.array(results))
+@pytest.mark.filterwarnings('ignore::DeprecationWarning')
+@pytest.mark.parametrize('filter_function', [events_split_nans, filter_candidates_remove_nans])
+def test_filter_function(filter_function):
+    candidates = [[0, 1], [2, 3]]
+    values = np.array([(np.nan, np.nan), (0, 0), (0, 0), (0, 0)])
 
-    if 'values_split' in expected:
-        results = events_split_nans(
-            params['candidates'],
-            params['values'],
-        )
-        assert np.all(np.array(expected['values_split']) == np.array(results))
+    filter_function(candidates=candidates, values=values)
+
+
+@pytest.mark.parametrize('filter_function', [events_split_nans, filter_candidates_remove_nans])
+def test_filter_function_deprecated(filter_function):
+    candidates = [[0, 1], [2, 3]]
+    values = np.array([(np.nan, np.nan), (0, 0), (0, 0), (0, 0)])
+
+    with pytest.raises(DeprecationWarning):
+        filter_function(candidates=candidates, values=values)
+
+
+@pytest.mark.parametrize('filter_function', [events_split_nans, filter_candidates_remove_nans])
+def test_filter_function_removed(filter_function):
+    candidates = [[0, 1], [2, 3]]
+    values = np.array([(np.nan, np.nan), (0, 0), (0, 0), (0, 0)])
+
+    with pytest.raises(DeprecationWarning) as info:
+        filter_function(candidates=candidates, values=values)
+
+    regex = re.compile(r'.*will be removed in v(?P<version>[0-9]*[.][0-9]*[.][0-9]*)[.)].*')
+
+    msg = info.value.args[0]
+    remove_version = regex.match(msg).groupdict()['version']
+    current_version = __version__.split('+')[0]
+    assert current_version < remove_version, (
+        f'utils/filters.py was planned to be removed in v{remove_version}. '
+        f'Current version is v{current_version}.'
+    )