Repository: aeye-lab/pymovements
Pull request #1080 (2025-04-08 16:20:52)

depr: deprecate `utils.aois` in favor of `TextStimulus.get_aoi()`
## :warning: Deprecation

`utils.aois` is deprecated. Please use `TextStimulus.get_aoi()` instead.
`utils.aois` will be removed in pymovements v0.26.0

## Description

Deprecate `utils.aois` in favor of `TextStimulus.get_aoi()`

The following warnings are printed:

```
DeprecationWarning: Call to deprecated function (or staticmethod) get_aoi(). (Please use TextStimulus.get_aoi() instead. This module will be removed in v0.26.0.) -- Deprecated since version v0.21.1.
```

Related to #999

## Implemented changes

- [x] moved logic from `utils/aois.py` to `stimulus/text.py`
- [x] implement `TextStimulus.get_aoi()`
- [x] update usage in `GazeDataFrame` and `EventDataFrame`
- [x] created deprecated alias function in `utils/aois.py`


## How Has This Been Tested?

- [x] created tests for `TextStimulus.get_aoi()` in `tests/unit/stimulus/text_test.py`
- [x] add test that functions are correctly deprecated
- [x] add test that functions are removed in planned version

## 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/1080?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`ca50595`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/ca505956d32c5b8b32cd8b79bf46c2955538243b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`50b1dda`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/50b1dda8cfc3eb5f1663e1da37bdf1eb1969d125?dropdown=coverage&el=desc&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     #1080   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           87        87           
  Lines         3717      3721    +4     
  Branches       639       639           
=========================================
+ Hits          3717      3721    +4     
```

</details>

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

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

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

Patch:
diff --git a/src/pymovements/events/frame.py b/src/pymovements/events/frame.py
index 558b26f..e5b9bca 100644
--- a/src/pymovements/events/frame.py
+++ b/src/pymovements/events/frame.py
@@ -30,7 +30,6 @@ from tqdm import tqdm
 from pymovements.events.properties import duration
 from pymovements.stimulus.text import TextStimulus
 from pymovements.utils import checks
-from pymovements.utils.aois import get_aoi
 
 
 class EventDataFrame:
@@ -325,7 +324,7 @@ class EventDataFrame:
         """
         self.unnest()
         aois = [
-            get_aoi(aoi_dataframe, row, 'location_x', 'location_y')
+            aoi_dataframe.get_aoi(row=row, x_eye='location_x', y_eye='location_y')
             for row in tqdm(self.frame.iter_rows(named=True))
         ]
         aoi_df = pl.concat(aois)
diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py
index b3e0b97..3db904f 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -34,7 +34,6 @@ from tqdm import tqdm
 import pymovements as pm  # pylint: disable=cyclic-import
 from pymovements.gaze import transforms
 from pymovements.gaze.experiment import Experiment
-from pymovements.utils.aois import get_aoi
 from pymovements.utils.checks import check_is_mutual_exclusive
 
 
@@ -1024,7 +1023,7 @@ class GazeDataFrame:
             raise ValueError('neither position nor pixel in gaze dataframe, one needed for mapping')
 
         aois = [
-            get_aoi(aoi_dataframe, row, x_eye, y_eye)
+            aoi_dataframe.get_aoi(row=row, x_eye=x_eye, y_eye=y_eye)
             for row in tqdm(self.frame.iter_rows(named=True))
         ]
         aoi_df = pl.concat(aois)
diff --git a/src/pymovements/stimulus/text.py b/src/pymovements/stimulus/text.py
index f98afb9..0fdfedb 100644
--- a/src/pymovements/stimulus/text.py
+++ b/src/pymovements/stimulus/text.py
@@ -26,6 +26,8 @@ from typing import Any
 
 import polars as pl
 
+from pymovements.utils import checks
+
 
 class TextStimulus:
     """A DataFrame for the text stimulus that the gaze data was recorded on.
@@ -112,6 +114,40 @@ class TextStimulus:
             for df in self.aois.partition_by(by=by, as_dict=False)
         ]
 
+    def get_aoi(
+            self,
+            *,
+            row: pl.DataFrame.row,
+            x_eye: str,
+            y_eye: str,
+    ) -> pl.DataFrame:
+        """Given eye movement and aoi dataframe, return aoi.
+
+        If `width` is used, calculation: start_x_column <= x_eye < start_x_column + width.
+        If `end_x_column` is used, calculation: start_x_column <= x_eye < end_x_column.
+        Analog for y coordinate and height.
+
+        Parameters
+        ----------
+        row: pl.DataFrame.row
+            Eye movement row.
+        x_eye: str
+            Name of x eye coordinate.
+        y_eye: str
+            Name of y eye coordinate.
+
+        Returns
+        -------
+        pl.DataFrame
+            Looked at area of interest.
+
+        Raises
+        ------
+        ValueError
+            If width and end_TYPE_column is None.
+        """
+        return _get_aoi(self, row=row, x_eye=x_eye, y_eye=y_eye)
+
 
 def from_file(
         aoi_path: str | Path,
@@ -191,3 +227,87 @@ def from_file(
         end_y_column=end_y_column,
         page_column=page_column,
     )
+
+
+def _get_aoi(
+        aoi_dataframe: TextStimulus,
+        row: pl.DataFrame.row,
+        x_eye: str,
+        y_eye: str,
+) -> pl.DataFrame:
+    """Given eye movement and aoi dataframe, return aoi.
+
+    If `width` is used, calculation: start_x_column <= x_eye < start_x_column + width.
+    If `end_x_column` is used, calculation: start_x_column <= x_eye < end_x_column.
+    Analog for y coordinate and height.
+
+    .. deprecated:: v0.21.1
+       Please use :py:meth:`~pymovements.TextStimulus.get_aoi()` instead.
+       This function will be removed in v0.26.0.
+
+    Parameters
+    ----------
+    aoi_dataframe: TextStimulus
+        Text dataframe to containing area of interests.
+    row: pl.DataFrame.row
+        Eye movement row.
+    x_eye: str
+        Name of x eye coordinate.
+    y_eye: str
+        Name of y eye coordinate.
+
+    Returns
+    -------
+    pl.DataFrame
+        Looked at area of interest.
+
+    Raises
+    ------
+    ValueError
+        If width and end_TYPE_column is None.
+    """
+    if aoi_dataframe.width_column is not None:
+        checks.check_is_none_is_mutual(
+            height_column=aoi_dataframe.width_column,
+            width_column=aoi_dataframe.height_column,
+        )
+        aoi = aoi_dataframe.aois.filter(
+            (aoi_dataframe.aois[aoi_dataframe.start_x_column] <= row[x_eye]) &
+            (
+                row[x_eye] <
+                aoi_dataframe.aois[aoi_dataframe.start_x_column] +
+                aoi_dataframe.aois[aoi_dataframe.width_column]
+            ) &
+            (aoi_dataframe.aois[aoi_dataframe.start_y_column] <= row[y_eye]) &
+            (
+                row[y_eye] <
+                aoi_dataframe.aois[aoi_dataframe.start_y_column] +
+                aoi_dataframe.aois[aoi_dataframe.height_column]
+            ),
+        )
+
+        if aoi.is_empty():
+            aoi.extend(pl.from_dict({col: None for col in aoi.columns}))
+        return aoi
+
+    if aoi_dataframe.end_x_column is not None:
+        checks.check_is_none_is_mutual(
+            end_x_column=aoi_dataframe.end_x_column,
+            end_y_column=aoi_dataframe.end_y_column,
+        )
+        aoi = aoi_dataframe.aois.filter(
+            # x-coordinate: within bounding box
+            (aoi_dataframe.aois[aoi_dataframe.start_x_column] <= row[x_eye]) &
+            (row[x_eye] < aoi_dataframe.aois[aoi_dataframe.end_x_column]) &
+            # y-coordinate: within bounding box
+            (aoi_dataframe.aois[aoi_dataframe.start_y_column] <= row[y_eye]) &
+            (row[y_eye] < aoi_dataframe.aois[aoi_dataframe.end_y_column]),
+        )
+
+        if aoi.is_empty():
+            aoi.extend(pl.from_dict({col: None for col in aoi.columns}))
+
+        return aoi
+    raise ValueError(
+        'either TextStimulus.width or TextStimulus.end_x_column must be defined',
+    )
diff --git a/src/pymovements/utils/aois.py b/src/pymovements/utils/aois.py
index 261210f..a3dcda3 100644
--- a/src/pymovements/utils/aois.py
+++ b/src/pymovements/utils/aois.py
@@ -17,15 +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.
-"""Aoi utilities."""
+"""AOI utilities.
+
+.. deprecated:: v0.21.1
+   Please use :py:meth:`~pymovements.TextStimulus.get_aoi()` instead.
+   This module will be removed in v0.26.0.
+"""
 from __future__ import annotations
 
 import polars as pl
+from deprecated.sphinx import deprecated
 
 from pymovements.stimulus import TextStimulus
-from pymovements.utils import checks
 
 
+@deprecated(
+    reason='Please use TextStimulus.get_aoi() instead. '
+           'This function will be removed in v0.26.0.',
+    version='v0.21.1',
+)
 def get_aoi(
         aoi_dataframe: TextStimulus,
         row: pl.DataFrame.row,
@@ -38,6 +48,10 @@ def get_aoi(
     If `end_x_column` is used, calculation: start_x_column <= x_eye < end_x_column.
     Analog for y coordinate and height.
 
+    .. deprecated:: v0.21.1
+       Please use :py:meth:`~pymovements.TextStimulus.get_aoi()` instead.
+       This function will be removed in v0.26.0.
+
     Parameters
     ----------
     aoi_dataframe: TextStimulus
@@ -59,47 +73,4 @@ def get_aoi(
     ValueError
         If width and end_TYPE_column is None.
     """
-    if aoi_dataframe.width_column is not None:
-        checks.check_is_none_is_mutual(
-            height_column=aoi_dataframe.width_column,
-            width_column=aoi_dataframe.height_column,
-        )
-        aoi = aoi_dataframe.aois.filter(
-            (aoi_dataframe.aois[aoi_dataframe.start_x_column] <= row[x_eye]) &
-            (
-                row[x_eye] <
-                aoi_dataframe.aois[aoi_dataframe.start_x_column] +
-                aoi_dataframe.aois[aoi_dataframe.width_column]
-            ) &
-            (aoi_dataframe.aois[aoi_dataframe.start_y_column] <= row[y_eye]) &
-            (
-                row[y_eye] <
-                aoi_dataframe.aois[aoi_dataframe.start_y_column] +
-                aoi_dataframe.aois[aoi_dataframe.height_column]
-            ),
-        )
-
-        if aoi.is_empty():
-            aoi.extend(pl.from_dict({col: None for col in aoi.columns}))
-        return aoi
-    if aoi_dataframe.end_x_column is not None:
-        checks.check_is_none_is_mutual(
-            end_x_column=aoi_dataframe.end_x_column,
-            end_y_column=aoi_dataframe.end_y_column,
-        )
-        aoi = aoi_dataframe.aois.filter(
-            # x-coordinate: within bounding box
-            (aoi_dataframe.aois[aoi_dataframe.start_x_column] <= row[x_eye]) &
-            (row[x_eye] < aoi_dataframe.aois[aoi_dataframe.end_x_column]) &
-            # y-coordinate: within bounding box
-            (aoi_dataframe.aois[aoi_dataframe.start_y_column] <= row[y_eye]) &
-            (row[y_eye] < aoi_dataframe.aois[aoi_dataframe.end_y_column]),
-        )
-
-        if aoi.is_empty():
-            aoi.extend(pl.from_dict({col: None for col in aoi.columns}))
-
-        return aoi
-    raise ValueError(
-        'either aoi_dataframe.width or aoi_dataframe.end_x_column have to be not None',
-    )
+    return aoi_dataframe.get_aoi(row=row, x_eye=x_eye, y_eye=y_eye)


Test patch:
diff --git a/tests/unit/events/event_aoi_mapping_test.py b/tests/unit/events/event_aoi_mapping_test.py
index 22d8904..62f6042 100644
--- a/tests/unit/events/event_aoi_mapping_test.py
+++ b/tests/unit/events/event_aoi_mapping_test.py
@@ -313,4 +313,4 @@ def test_map_to_aois_raises_value_error_missing_width_height(dataset):
     with pytest.raises(ValueError) as excinfo:
         dataset.events[0].map_to_aois(aoi_df)
     msg, = excinfo.value.args
-    assert msg == 'either aoi_dataframe.width or aoi_dataframe.end_x_column have to be not None'
+    assert msg == 'either TextStimulus.width or TextStimulus.end_x_column must be defined'
diff --git a/tests/unit/stimulus/text_test.py b/tests/unit/stimulus/text_test.py
index 0963f3f..ba89f5f 100644
--- a/tests/unit/stimulus/text_test.py
+++ b/tests/unit/stimulus/text_test.py
@@ -24,7 +24,7 @@ import polars
 import pytest
 from polars.testing import assert_frame_equal
 
-import pymovements as pm
+from pymovements.stimulus import text
 
 
 EXPECTED_DF = polars.DataFrame(
@@ -192,7 +192,7 @@ EXPECTED_DF = polars.DataFrame(
     ],
 )
 def test_text_stimulus(aoi_file, custom_read_kwargs, expected):
-    aois = pm.stimulus.text.from_file(
+    aois = text.from_file(
         aoi_file,
         aoi_column='char',
         start_x_column='top_left_x',
@@ -213,7 +213,7 @@ def test_text_stimulus(aoi_file, custom_read_kwargs, expected):
 
 def test_text_stimulus_unsupported_format():
     with pytest.raises(ValueError) as excinfo:
-        pm.stimulus.text.from_file(
+        text.from_file(
             'tests/files/toy_text_1_1_aoi.pickle',
             aoi_column='char',
             start_x_column='top_left_x',
@@ -244,7 +244,7 @@ def test_text_stimulus_unsupported_format():
     ],
 )
 def test_text_stimulus_splitting(aoi_file, custom_read_kwargs):
-    aois_df = pm.stimulus.text.from_file(
+    aois_df = text.from_file(
         aoi_file,
         aoi_column='char',
         start_x_column='top_left_x',
@@ -275,7 +275,7 @@ def test_text_stimulus_splitting(aoi_file, custom_read_kwargs):
     ],
 )
 def test_text_stimulus_splitting_unique_within(aoi_file, custom_read_kwargs):
-    aois_df = pm.stimulus.text.from_file(
+    aois_df = text.from_file(
         aoi_file,
         aoi_column='char',
         start_x_column='top_left_x',
@@ -306,7 +306,7 @@ def test_text_stimulus_splitting_unique_within(aoi_file, custom_read_kwargs):
     ],
 )
 def test_text_stimulus_splitting_different_between(aoi_file, custom_read_kwargs):
-    aois_df = pm.stimulus.text.from_file(
+    aois_df = text.from_file(
         aoi_file,
         aoi_column='char',
         start_x_column='top_left_x',
@@ -324,3 +324,37 @@ def test_text_stimulus_splitting_different_between(aoi_file, custom_read_kwargs)
         unique_values.extend(unique_value)
 
     assert len(unique_values) == len(set(unique_values))
+
+
+@pytest.fixture(name='text_stimulus')
+def fixture_text_stimulus():
+    yield text.from_file(
+        'tests/files/toy_text_1_1_aoi.csv',
+        aoi_column='word',
+        start_x_column='top_left_x',
+        start_y_column='top_left_y',
+        end_x_column='bottom_left_x',
+        end_y_column='bottom_left_y',
+        page_column='page',
+    )
+
+
+@pytest.mark.parametrize(
+    ('row', 'expected_aoi'),
+    [
+        pytest.param(
+            {'x': 400, 'y': 125},
+            'A',
+            id='400,125',
+        ),
+        pytest.param(
+            {'x': 500, 'y': 300},
+            None,
+            id='500,300',
+        ),
+    ],
+)
+def test_text_stimulus_get_aoi(text_stimulus, row, expected_aoi):
+    aoi = text_stimulus.get_aoi(row=row, x_eye='x', y_eye='y')
+
+    assert aoi['char'].first() == expected_aoi
diff --git a/tests/unit/utils/aois_test.py b/tests/unit/utils/aois_test.py
new file mode 100644
index 0000000..91cd90a
--- /dev/null
+++ b/tests/unit/utils/aois_test.py
@@ -0,0 +1,76 @@
+# 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.
+"""Tests deprecated utils.parsing."""
+import re
+
+import pytest
+from polars.testing import assert_frame_equal
+
+from pymovements import __version__
+from pymovements.stimulus import text
+from pymovements.utils.aois import get_aoi
+
+
+@pytest.fixture(name='text_stimulus')
+def fixture_text_stimulus():
+    yield text.from_file(
+        'tests/files/toy_text_1_1_aoi.csv',
+        aoi_column='word',
+        start_x_column='top_left_x',
+        start_y_column='top_left_y',
+        end_x_column='bottom_left_x',
+        end_y_column='bottom_left_y',
+        page_column='page',
+    )
+
+
+@pytest.mark.filterwarnings('ignore::DeprecationWarning')
+@pytest.mark.parametrize(
+    'row',
+    [
+        {'x': 400, 'y': 125},
+        {'x': 500, 'y': 300},
+    ],
+)
+def test_get_aoi_equal_to_new(text_stimulus, row):
+    aoi_old = get_aoi(text_stimulus, row, 'x', 'y')
+    aoi_new = text_stimulus.get_aoi(row=row, x_eye='x', y_eye='y')
+
+    assert_frame_equal(aoi_old, aoi_new)
+
+
+def test_get_aoi_deprecated(text_stimulus):
+    with pytest.raises(DeprecationWarning):
+        get_aoi(text_stimulus, {'x': 400, 'y': 125}, 'x', 'y')
+
+
+def test_get_aoi_removed(text_stimulus):
+    with pytest.raises(DeprecationWarning) as info:
+        get_aoi(text_stimulus, {'x': 400, 'y': 125}, 'x', 'y')
+
+    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/parsing.py was planned to be removed in v{remove_version}. '
+        f'Current version is v{current_version}.'
+    )