Repository: aeye-lab/pymovements (python)

Issue:
Allow custom patterns for extracting logged metadata from ASC files
## Description of the problem

Currently, the `from_asc()` only supports custom regex patterns via the `patterns` argument, which adds the captured values to columns in the dataframe. It would be useful if we could also extract session-level metadata (e.g., the stimulus randomization order, which we log in the ASC file).

## Description of a solution

1. Add another argument to `from_asc()` (e.g., `metadata_patterns`) that accepts custom regular expressions. The captured values should be added to the metadata dictionary returned by `parse_eyelink()`.
2. Return the metadata dictionary from `from_asc()`.

## Sample Code

```py
>>> data, metadata = pm.gaze.from_asc("foo.asc", metadata_patterns=[r"stimulus order: (?P<stimulus_order>.+)"])
>>> metadata
{"stimulus_order": "order1", ...}
```

The code should probably either warn the user in case there are multiple logged messages that match the pattern, or collect all of them in a list (e.g., `{"stimulus_order": ["order1", "order2"], ...}`)

Pull request description:
feat!: Custom patterns for parsing logged metadata in ASC files
## Description

Fixes #758

## Implemented changes

- [x] **BREAKING:** Return metadata dictionary from `from_asc()`
- [x] Add argument `matadata_patterns` to `from_asc()` and `parse_eyelink()`
- [x] Default value of `None` for metadata keys specified in `metadata_patterns` that are not present in the ASC file

## TODO

- [x] Make pylint happy
- [x] Fix test coverage

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change is or requires a documentation update

## How Has This Been Tested?

- [x] Metadata returned
- [x] Test default `None` values

## 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


Patch:
diff --git a/src/pymovements/dataset/dataset_files.py b/src/pymovements/dataset/dataset_files.py
index dfc4c40..1553fdd 100644
--- a/src/pymovements/dataset/dataset_files.py
+++ b/src/pymovements/dataset/dataset_files.py
@@ -377,7 +377,7 @@ def load_gaze_file(
             column_schema_overrides=definition.filename_format_schema_overrides['gaze'],
         )
     elif filepath.suffix == '.asc':
-        gaze_df = from_asc(
+        gaze_df, _ = from_asc(
             filepath,
             experiment=definition.experiment,
             add_columns=add_columns,
diff --git a/src/pymovements/gaze/io.py b/src/pymovements/gaze/io.py
index 7224774..18868e3 100644
--- a/src/pymovements/gaze/io.py
+++ b/src/pymovements/gaze/io.py
@@ -271,21 +271,25 @@ def from_csv(
 def from_asc(
         file: str | Path,
         *,
-        patterns: str | list | None = 'eyelink',
+        patterns: str | list[dict[str, Any] | str] | None = 'eyelink',
+        metadata_patterns: list[dict[str, Any] | str] | None = None,
         schema: dict[str, Any] | None = None,
         experiment: Experiment | None = None,
         add_columns: dict[str, str] | None = None,
-        column_schema_overrides: dict[str, type] | None = None,
-) -> GazeDataFrame:
+        column_schema_overrides: dict[str, Any] | None = None,
+) -> tuple[GazeDataFrame, dict[str, Any]]:
     """Initialize a :py:class:`pymovements.gaze.gaze_dataframe.GazeDataFrame`.
 
     Parameters
     ----------
     file: str | Path
         Path of IPC/feather file.
-    patterns: str | list | None
-        list of patterns to match for additional columns or a key identifier of eye tracker specific
+    patterns: str | list[dict[str, Any] | str] | None
+        List of patterns to match for additional columns or a key identifier of eye tracker specific
         default patterns. Supported values are: eyelink. (default: 'eyelink')
+    metadata_patterns: list[dict[str, Any] | str] | None
+        List of patterns to match for extracting metadata from custom logged messages.
+        (default: None)
     schema: dict[str, Any] | None
         Dictionary to optionally specify types of columns parsed by patterns. (default: None)
     experiment: Experiment | None
@@ -293,14 +297,14 @@ def from_asc(
     add_columns: dict[str, str] | None
         Dictionary containing columns to add to loaded data frame.
         (default: None)
-    column_schema_overrides:  dict[str, type] | None
+    column_schema_overrides: dict[str, Any] | None
         Dictionary containing types for columns.
         (default: None)
 
     Returns
     -------
-    GazeDataFrame
-        The gaze data frame read from the asc file.
+    tuple[GazeDataFrame, dict[str, Any]]
+        The gaze data frame and a metadata dictionary read from the asc file.
 
     Examples
     --------
@@ -308,7 +312,7 @@ def from_asc(
     We can then load the data into a ``GazeDataFrame``:
 
     >>> from pymovements.gaze.io import from_asc
-    >>> gaze = from_asc(file='tests/files/eyelink_monocular_example.asc', patterns='eyelink')
+    >>> gaze, metadata = from_asc(file='tests/files/eyelink_monocular_example.asc')
     >>> gaze.frame
     shape: (16, 3)
     ┌─────────┬───────┬────────────────┐
@@ -328,7 +332,8 @@ def from_asc(
     │ 2339290 ┆ 618.0 ┆ [637.6, 531.4] │
     │ 2339291 ┆ 618.0 ┆ [637.3, 531.2] │
     └─────────┴───────┴────────────────┘
-
+    >>> metadata['sampling_rate']
+    1000.0
     """
     if isinstance(patterns, str):
         if patterns == 'eyelink':
@@ -338,7 +343,9 @@ def from_asc(
             raise ValueError(f"unknown pattern key '{patterns}'. Supported keys are: eyelink")
 
     # Read data.
-    gaze_data, _ = parse_eyelink(file, patterns=patterns, schema=schema)
+    gaze_data, metadata = parse_eyelink(
+        file, patterns=patterns, schema=schema, metadata_patterns=metadata_patterns,
+    )
 
     if add_columns is not None:
         gaze_data = gaze_data.with_columns([
@@ -361,7 +368,7 @@ def from_asc(
         time_unit='ms',
         pixel_columns=['x_pix', 'y_pix'],
     )
-    return gaze_df
+    return gaze_df, metadata
 
 
 def from_ipc(
diff --git a/src/pymovements/utils/parsing.py b/src/pymovements/utils/parsing.py
index a2e12f4..849cb86 100755
--- a/src/pymovements/utils/parsing.py
+++ b/src/pymovements/utils/parsing.py
@@ -40,58 +40,58 @@ EYE_TRACKING_SAMPLE = re.compile(
 )
 
 EYELINK_META_REGEXES = [
-    {'pattern': r'\*\*\s+VERSION:\s+(?P<version_1>.*)\s+'},
-    {
-        'pattern': r'\*\*\s+DATE:\s+(?P<weekday>[A-Z,a-z]+)\s+(?P<month>[A-Z,a-z]+)'
-                   r'\s+(?P<day>\d\d?)\s+(?P<time>\d\d:\d\d:\d\d)\s+(?P<year>\d{4})\s*',
-    },
-    {'pattern': r'\*\*\s+(?P<version_2>EYELINK.*)'},
-    {
-        'pattern': r'MSG\s+\d+[.]?\d*\s+DISPLAY_COORDS\s+(?P<resolution>.*)',
-    },
-    {
-        'pattern': r'MSG\s+\d+[.]?\d*\s+RECCFG\s+(?P<tracking_mode>[A-Z,a-z]+)\s+'
-                   r'(?P<sampling_rate>\d+)\s+'
-                   r'(?P<file_sample_filter>(0|1|2))\s+'
-                   r'(?P<link_sample_filter>(0|1|2))\s+'
-                   r'(?P<tracked_eye>(L|R|LR))\s*',
-    },
-    {
-        'pattern': r'PUPIL\s+(?P<pupil_data_type>(AREA|DIAMETER))\s*',
-    },
-    {
-        'pattern': r'MSG\s+\d+[.]?\d*\s+ELCLCFG\s+(?P<mount_configuration>.*)',
-    },
+    {'pattern': re.compile(regex)} for regex in (
+        r'\*\*\s+VERSION:\s+(?P<version_1>.*)\s+',
+        (
+            r'\*\*\s+DATE:\s+(?P<weekday>[A-Z,a-z]+)\s+(?P<month>[A-Z,a-z]+)'
+            r'\s+(?P<day>\d\d?)\s+(?P<time>\d\d:\d\d:\d\d)\s+(?P<year>\d{4})\s*'
+        ),
+        r'\*\*\s+(?P<version_2>EYELINK.*)',
+        r'MSG\s+\d+[.]?\d*\s+DISPLAY_COORDS\s+(?P<resolution>.*)',
+        (
+            r'MSG\s+\d+[.]?\d*\s+RECCFG\s+(?P<tracking_mode>[A-Z,a-z]+)\s+'
+            r'(?P<sampling_rate>\d+)\s+'
+            r'(?P<file_sample_filter>(0|1|2))\s+'
+            r'(?P<link_sample_filter>(0|1|2))\s+'
+            r'(?P<tracked_eye>(L|R|LR))\s*'
+        ),
+        r'PUPIL\s+(?P<pupil_data_type>(AREA|DIAMETER))\s*',
+        r'MSG\s+\d+[.]?\d*\s+ELCLCFG\s+(?P<mount_configuration>.*)',
+    )
 ]
 
-VALIDATION_REGEX = (
+VALIDATION_REGEX = re.compile(
     r'MSG\s+(?P<timestamp>\d+[.]?\d*)\s+!CAL\s+VALIDATION\s+HV'
     r'(?P<num_points>\d\d?).*'
     r'(?P<tracked_eye>LEFT|RIGHT)\s+'
     r'(?P<error>\D*)\s+'
     r'(?P<validation_score_avg>\d.\d\d)\s+avg\.\s+'
-    r'(?P<validation_score_max>\d.\d\d)\s+max'
+    r'(?P<validation_score_max>\d.\d\d)\s+max',
 )
 
-BLINK_START_REGEX = r'SBLINK\s+(R|L)\s+(?P<timestamp>(\d+[.]?\d*))\s*'
-BLINK_STOP_REGEX = (
+BLINK_START_REGEX = re.compile(r'SBLINK\s+(R|L)\s+(?P<timestamp>(\d+[.]?\d*))\s*')
+BLINK_STOP_REGEX = re.compile(
     r'EBLINK\s+(R|L)\s+(?P<timestamp_start>(\d+[.]?\d*))\s+'
-    r'(?P<timestamp_end>(\d+[.]?\d*))\s+(?P<duration_ms>(\d+[.]?\d*))\s*'
+    r'(?P<timestamp_end>(\d+[.]?\d*))\s+(?P<duration_ms>(\d+[.]?\d*))\s*',
+)
+INVALID_SAMPLE_REGEX = re.compile(
+    r'(?P<timestamp>(\d+[.]?\d*))\s+\.\s+\.\s+0\.0\s+0\.0\s+\.\.\.\s*',
 )
-INVALID_SAMPLE_REGEX = r'(?P<timestamp>(\d+[.]?\d*))\s+\.\s+\.\s+0\.0\s+0\.0\s+\.\.\.\s*'
 
-CALIBRATION_TIMESTAMP_REGEX = r'MSG\s+(?P<timestamp>\d+[.]?\d*)\s+!CAL\s*\n'
+CALIBRATION_TIMESTAMP_REGEX = re.compile(r'MSG\s+(?P<timestamp>\d+[.]?\d*)\s+!CAL\s*\n')
 
-CALIBRATION_REGEX = (
+CALIBRATION_REGEX = re.compile(
     r'>+\s+CALIBRATION\s+\(HV(?P<num_points>\d\d?),'
     r'(?P<type>.*)\).*'
-    r'(?P<tracked_eye>RIGHT|LEFT):\s+<{9}'
+    r'(?P<tracked_eye>RIGHT|LEFT):\s+<{9}',
 )
 
-START_RECORDING_REGEX = r'START\s+(?P<timestamp>(\d+[.]?\d*))\s+(RIGHT|LEFT)\s+(?P<types>.*)'
-STOP_RECORDING_REGEX = (
+START_RECORDING_REGEX = re.compile(
+    r'START\s+(?P<timestamp>(\d+[.]?\d*))\s+(RIGHT|LEFT)\s+(?P<types>.*)',
+)
+STOP_RECORDING_REGEX = re.compile(
     r'END\s+(?P<timestamp>(\d+[.]?\d*))\s+\s+(?P<types>.*)\s+RES\s+'
-    r'(?P<xres>[\d\.]*)\s+(?P<yres>[\d\.]*)\s*'
+    r'(?P<xres>[\d\.]*)\s+(?P<yres>[\d\.]*)\s*',
 )
 
 
@@ -115,12 +115,12 @@ def check_nan(sample_location: str) -> float:
     return ret
 
 
-def compile_patterns(patterns: list[dict[str, Any]]) -> list[dict[str, Any]]:
+def compile_patterns(patterns: list[dict[str, Any] | str]) -> list[dict[str, Any]]:
     """Compile patterns from strings.
 
     Parameters
     ----------
-    patterns: list[dict[str, Any]]
+    patterns: list[dict[str, Any] | str]
         The list of patterns to compile.
 
     Returns
@@ -161,24 +161,25 @@ def compile_patterns(patterns: list[dict[str, Any]]) -> list[dict[str, Any]]:
     return compiled_patterns
 
 
-def get_additional_columns(compiled_patterns: list[dict[str, Any]]) -> set[str]:
-    """Get additionally needed columns from compiled patterns."""
-    additional_columns = set()
+def get_pattern_keys(compiled_patterns: list[dict[str, Any]], pattern_key: str) -> set[str]:
+    """Get names of capture groups or column/metadata keys."""
+    keys = set()
 
     for compiled_pattern_dict in compiled_patterns:
-        if 'column' in compiled_pattern_dict:
-            additional_columns.add(compiled_pattern_dict['column'])
+        if pattern_key in compiled_pattern_dict:
+            keys.add(compiled_pattern_dict[pattern_key])
 
-        for column in compiled_pattern_dict['pattern'].groupindex.keys():
-            additional_columns.add(column)
+        for key in compiled_pattern_dict['pattern'].groupindex.keys():
+            keys.add(key)
 
-    return additional_columns
+    return keys
 
 
 def parse_eyelink(
         filepath: Path | str,
-        patterns: list[dict[str, Any]] | None = None,
+        patterns: list[dict[str, Any] | str] | None = None,
         schema: dict[str, Any] | None = None,
+        metadata_patterns: list[dict[str, Any] | str] | None = None,
 ) -> tuple[pl.DataFrame, dict[str, Any]]:
     """Process EyeLink asc file.
 
@@ -186,10 +187,12 @@ def parse_eyelink(
     ----------
     filepath: Path | str
         file name of ascii file to convert.
-    patterns: list[dict[str, Any]] | None
-        list of patterns to match for additional columns. (default: None)
+    patterns: list[dict[str, Any] | str] | None
+        List of patterns to match for additional columns. (default: None)
     schema: dict[str, Any] | None
         Dictionary to optionally specify types of columns parsed by patterns. (default: None)
+    metadata_patterns: list[dict[str, Any] | str] | None
+        list of patterns to match for additional metadata. (default: None)
 
     Returns
     -------
@@ -205,7 +208,11 @@ def parse_eyelink(
         patterns = []
     compiled_patterns = compile_patterns(patterns)
 
-    additional_columns = get_additional_columns(compiled_patterns)
+    if metadata_patterns is None:
+        metadata_patterns = []
+    compiled_metadata_patterns = compile_patterns(metadata_patterns)
+
+    additional_columns = get_pattern_keys(compiled_patterns, 'column')
     additional: dict[str, list[Any]] = {
         additional_column: [] for additional_column in additional_columns
     }
@@ -227,21 +234,14 @@ def parse_eyelink(
     # will return an empty string if the key does not exist
     metadata: defaultdict = defaultdict(str)
 
-    compiled_metadata_patterns = []
-    for metadata_pattern in EYELINK_META_REGEXES:
-        compiled_metadata_patterns.append({'pattern': re.compile(metadata_pattern['pattern'])})
+    # metadata keys specified by the user should have a default value of None
+    metadata_keys = get_pattern_keys(compiled_metadata_patterns, 'key')
+    for key in metadata_keys:
+        metadata[key] = None
 
-    compiled_validation_pattern = re.compile(VALIDATION_REGEX)
-    compiled_calibration_pattern = re.compile(CALIBRATION_REGEX)
-    compiled_calibration_timestamp = re.compile(CALIBRATION_TIMESTAMP_REGEX)
-    cal_timestamp = ''
-
-    compiled_blink_start = re.compile(BLINK_START_REGEX)
-    compiled_blink_stop = re.compile(BLINK_STOP_REGEX)
-    compiled_invalid_sample = re.compile(INVALID_SAMPLE_REGEX)
+    compiled_metadata_patterns.extend(EYELINK_META_REGEXES)
 
-    compiled_recording_start = re.compile(START_RECORDING_REGEX)
-    compiled_recording_stop = re.compile(STOP_RECORDING_REGEX)
+    cal_timestamp = ''
 
     validations = []
     calibrations = []
@@ -264,8 +264,7 @@ def parse_eyelink(
                     current_additional[current_column] = pattern_dict['value']
 
                 else:
-                    for column, value in match.groupdict().items():
-                        current_additional[column] = value
+                    current_additional.update(match.groupdict())
 
         if cal_timestamp:
             # if a calibration timestamp has been found, the next line will be a
@@ -277,15 +276,15 @@ def parse_eyelink(
                     'timestamp': cal_timestamp,
                     **match.groupdict(),
                 }
-                if (match := compiled_calibration_pattern.match(line))
+                if (match := CALIBRATION_REGEX.match(line))
                 else {'timestamp': cal_timestamp},
             )
             cal_timestamp = ''
 
-        elif compiled_blink_start.match(line):
+        elif BLINK_START_REGEX.match(line):
             blink = True
 
-        elif match := compiled_blink_stop.match(line):
+        elif match := BLINK_STOP_REGEX.match(line):
             blink = False
             parsed_blink = match.groupdict()
             blink_info = {
@@ -297,10 +296,10 @@ def parse_eyelink(
             num_blink_samples = 0
             blinks.append(blink_info)
 
-        elif match := compiled_recording_start.match(line):
+        elif match := START_RECORDING_REGEX.match(line):
             start_recording_timestamp = match.groupdict()['timestamp']
 
-        elif match := compiled_recording_stop.match(line):
+        elif match := STOP_RECORDING_REGEX.match(line):
             stop_recording_timestamp = match.groupdict()['timestamp']
             block_duration = float(stop_recording_timestamp) - float(start_recording_timestamp)
 
@@ -326,50 +325,52 @@ def parse_eyelink(
             for additional_column in additional_columns:
                 samples[additional_column].append(current_additional[additional_column])
 
-            if (match := compiled_invalid_sample.match(line)) and not blink:
-                invalid_samples.append(match.groupdict()['timestamp'])
-            elif compiled_invalid_sample.match(line) and blink:
-                num_blink_samples += 1
+            if match := INVALID_SAMPLE_REGEX.match(line):
+                if blink:
+                    num_blink_samples += 1
+                else:
+                    invalid_samples.append(match.groupdict()['timestamp'])
 
-        elif match := compiled_calibration_timestamp.match(line):
+        elif match := CALIBRATION_TIMESTAMP_REGEX.match(line):
             cal_timestamp = match.groupdict()['timestamp']
 
-        elif match := compiled_validation_pattern.match(line):
+        elif match := VALIDATION_REGEX.match(line):
             validations.append(match.groupdict())
 
         elif compiled_metadata_patterns:
             for pattern_dict in compiled_metadata_patterns.copy():
                 if match := pattern_dict['pattern'].match(line):
-                    for column, value in match.groupdict().items():
-                        metadata[column] = value
+                    if 'value' in pattern_dict:
+                        metadata[pattern_dict['key']] = pattern_dict['value']
+
+                    else:
+                        metadata.update(match.groupdict())
 
                     # each metadata pattern should only match once
                     compiled_metadata_patterns.remove(pattern_dict)
 
-    if metadata:
-        # if the sampling rate is not found, we cannot calculate the data loss
-        actual_number_of_samples = len(samples['time'])
-
-        data_loss_ratio, data_loss_ratio_blinks = _calculate_data_loss(
-            blinks=blinks,
-            invalid_samples=invalid_samples,
-            actual_num_samples=actual_number_of_samples,
-            total_rec_duration=total_recording_duration,
-            sampling_rate=metadata['sampling_rate'],
-        )
+    if not metadata:
+        raise Warning('No metadata found. Please check the file for errors.')
 
-        pre_processed_metadata: dict[str, Any] = _pre_process_metadata(metadata)
+    # if the sampling rate is not found, we cannot calculate the data loss
+    actual_number_of_samples = len(samples['time'])
 
-        # is not yet pre-processed but should be
-        pre_processed_metadata['calibrations'] = calibrations
-        pre_processed_metadata['validations'] = validations
-        pre_processed_metadata['blinks'] = blinks
-        pre_processed_metadata['data_loss_ratio'] = data_loss_ratio
-        pre_processed_metadata['data_loss_ratio_blinks'] = data_loss_ratio_blinks
-        pre_processed_metadata['total_recording_duration_ms'] = total_recording_duration
+    data_loss_ratio, data_loss_ratio_blinks = _calculate_data_loss(
+        blinks=blinks,
+        invalid_samples=invalid_samples,
+        actual_num_samples=actual_number_of_samples,
+        total_rec_duration=total_recording_duration,
+        sampling_rate=metadata['sampling_rate'],
+    )
 
-    else:
-        raise Warning('No metadata found. Please check the file for errors.')
+    pre_processed_metadata: dict[str, Any] = _pre_process_metadata(metadata)
+    # is not yet pre-processed but should be
+    pre_processed_metadata['calibrations'] = calibrations
+    pre_processed_metadata['validations'] = validations
+    pre_processed_metadata['blinks'] = blinks
+    pre_processed_metadata['data_loss_ratio'] = data_loss_ratio
+    pre_processed_metadata['data_loss_ratio_blinks'] = data_loss_ratio_blinks
+    pre_processed_metadata['total_recording_duration_ms'] = total_recording_duration
 
     schema_overrides = {
         'time': pl.Float64,
@@ -378,8 +379,7 @@ def parse_eyelink(
         'pupil': pl.Float64,
     }
     if schema is not None:
-        for column, dtype in schema.items():
-            schema_overrides[column] = dtype
+        schema_overrides.update(schema)
 
     df = pl.from_dict(data=samples).cast(schema_overrides)
 


Test patch:
diff --git a/tests/functional/gaze_file_processing_test.py b/tests/functional/gaze_file_processing_test.py
index b824469..219fcaf 100644
--- a/tests/functional/gaze_file_processing_test.py
+++ b/tests/functional/gaze_file_processing_test.py
@@ -156,7 +156,7 @@ def test_gaze_file_processing(gaze_from_kwargs):
     elif file_extension in {'.feather', '.ipc'}:
         gaze = pm.gaze.from_ipc(**gaze_from_kwargs)
     elif file_extension == '.asc':
-        gaze = pm.gaze.from_asc(**gaze_from_kwargs)
+        gaze, _ = pm.gaze.from_asc(**gaze_from_kwargs)
 
     assert gaze is not None
 
diff --git a/tests/unit/gaze/io/asc_test.py b/tests/unit/gaze/io/asc_test.py
index 58e0ef4..40afb2b 100644
--- a/tests/unit/gaze/io/asc_test.py
+++ b/tests/unit/gaze/io/asc_test.py
@@ -131,11 +131,47 @@ import pymovements as pm
     ],
 )
 def test_from_asc_has_shape_and_schema(kwargs, expected_frame):
-    gaze = pm.gaze.from_asc(**kwargs)
+    gaze, _ = pm.gaze.from_asc(**kwargs)
 
     assert_frame_equal(gaze.frame, expected_frame, check_column_order=False)
 
 
+@pytest.mark.parametrize(
+    ('kwargs', 'expected_metadata'),
+    [
+        pytest.param(
+            {
+                'file': 'tests/files/eyelink_monocular_example.asc',
+                'metadata_patterns': [
+                    {'pattern': r'!V TRIAL_VAR SUBJECT_ID (?P<subject_id>-?\d+)'},
+                    r'!V TRIAL_VAR STIMULUS_COMBINATION_ID (?P<stimulus_combination_id>.+)',
+                ],
+            },
+            {
+                'subject_id': '-1',
+                'stimulus_combination_id': 'start',
+            },
+            id='eyelink_asc_metadata_patterns',
+        ),
+        pytest.param(
+            {
+                'file': 'tests/files/eyelink_monocular_example.asc',
+                'metadata_patterns': [r'inexistent pattern (?P<value>-?\d+)'],
+            },
+            {
+                'value': None,
+            },
+            id='eyelink_asc_metadata_pattern_not_found',
+        ),
+    ],
+)
+def test_from_asc_metadata_patterns(kwargs, expected_metadata):
+    _, metadata = pm.gaze.from_asc(**kwargs)
+
+    for key, value in expected_metadata.items():
+        assert metadata[key] == value
+
+
 @pytest.mark.parametrize(
     ('kwargs', 'exception', 'message'),
     [
diff --git a/tests/unit/utils/parsing_test.py b/tests/unit/utils/parsing_test.py
index 0b8347b..f33e89f 100644
--- a/tests/unit/utils/parsing_test.py
+++ b/tests/unit/utils/parsing_test.py
@@ -65,6 +65,7 @@ the task should be set to None again
 START	10000004 	RIGHT	SAMPLES	EVENTS
 10000004	  850.7	  717.5	  714.0	    0.0	...
 END	10000005 	SAMPLES	EVENTS	RES	  38.54	  31.12
+MSG 10000005 METADATA_1 123
 MSG 10000005 START_B
 the next line now should have the task column set to B
 START	10000006 	RIGHT	SAMPLES	EVENTS
@@ -85,11 +86,14 @@ MSG 10000012 STOP_TRIAL_2
 MSG 10000013 START_TRIAL_3
 the next line now should have the trial column set to 3
 START	10000014 	RIGHT	SAMPLES	EVENTS
+MSG 10000014 METADATA_2 abc
+MSG 10000014 METADATA_1 456
 10000014	  850.7	  717.5	  714.0	    0.0	...
 END	10000015 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000015 STOP_TRIAL_3
 MSG 10000016 STOP_B
 task and trial should be set to None again
+MSG 10000017 METADATA_3
 START	10000017 	RIGHT	SAMPLES	EVENTS
 10000017	  850.7	  717.5	  .	    0.0	...
 SBLINK R 10000018
@@ -125,6 +129,13 @@ PATTERNS = [
     },
 ]
 
+METADATA_PATTERNS = [
+    r'METADATA_1 (?P<metadata_1>\d+)',
+    {'pattern': r'METADATA_2 (?P<metadata_2>\w+)'},
+    {'pattern': r'METADATA_3', 'key': 'metadata_3', 'value': True},
+    {'pattern': r'METADATA_4', 'key': 'metadata_4', 'value': True},
+]
+
 EXPECTED_DF = pl.from_dict(
     {
         'time': [
@@ -174,6 +185,10 @@ EXPECTED_METADATA = {
         'eyes_recorded': 'binocular / monocular',
         'short_name': 'BTABLER',
     },
+    'metadata_1': '123',
+    'metadata_2': 'abc',
+    'metadata_3': True,
+    'metadata_4': None,
 }
 
 
@@ -184,6 +199,7 @@ def test_parse_eyelink(tmp_path):
     df, metadata = pm.utils.parsing.parse_eyelink(
         filepath,
         patterns=PATTERNS,
+        metadata_patterns=METADATA_PATTERNS,
     )
 
     assert_frame_equal(df, EXPECTED_DF, check_column_order=False)