Repository: mne-tools/mne-python

Issue:
BUG: EDF export failing on latest edfio
Our use of private `edfio._utils` is problematic, @cbrnr see [failed CI run](https://dev.azure.com/mne-tools/mne-python/_build/results?buildId=29598&view=logs&jobId=dded70eb-633c-5c42-e995-a7f8d1f99d91&j=dded70eb-633c-5c42-e995-a7f8d1f99d91&t=1ccededa-7e7e-5a92-87ce-e531d760d025) :
```
mne\export\tests\test_export.py:305: in test_channel_label_too_long_for_edf_raises_error
    raw.export(tmp_path / "test.edf")
<decorator-gen-346>:12: in export
    ???
mne\io\base.py:1792: in export
    export_raw(
<decorator-gen-599>:12: in export_raw
    ???
mne\export\_export.py:74: in export_raw
    from ._edf import _export_raw
mne\export\_edf.py:14: in <module>
    from edfio._utils import round_float_to_8_characters  # noqa: E402
E   ModuleNotFoundError: No module named 'edfio._utils'
```


Discussion:

Resolving patch:
diff --git a/mne/export/_edf.py b/mne/export/_edf.py
--- a/mne/export/_edf.py
+++ b/mne/export/_edf.py
@@ -4,6 +4,7 @@
 # Copyright the MNE-Python contributors.
 
 import datetime as dt
+from typing import Callable
 
 import numpy as np
 
@@ -11,7 +12,21 @@
 
 _check_edfio_installed()
 from edfio import Edf, EdfAnnotation, EdfSignal, Patient, Recording  # noqa: E402
-from edfio._utils import round_float_to_8_characters  # noqa: E402
+
+
+# copied from edfio (Apache license)
+def _round_float_to_8_characters(
+    value: float,
+    round_func: Callable[[float], int],
+) -> float:
+    if isinstance(value, int) or value.is_integer():
+        return value
+    length = 8
+    integer_part_length = str(value).find(".")
+    if integer_part_length == length:
+        return round_func(value)
+    factor = 10 ** (length - 1 - integer_part_length)
+    return round_func(value * factor) / factor
 
 
 def _export_raw(fname, raw, physical_range, add_ch_type):
@@ -52,7 +67,7 @@ def _export_raw(fname, raw, physical_range, add_ch_type):
             )
             data = np.pad(data, (0, int(pad_width)))
     else:
-        data_record_duration = round_float_to_8_characters(
+        data_record_duration = _round_float_to_8_characters(
             np.floor(sfreq) / sfreq, round
         )
         out_sfreq = np.floor(sfreq) / data_record_duration
diff --git a/mne/morph.py b/mne/morph.py
--- a/mne/morph.py
+++ b/mne/morph.py
@@ -190,7 +190,7 @@ def compute_source_morph(
     .. footbibliography::
     """
     src_data, kind, src_subject = _get_src_data(src)
-    subject_from = _check_subject_src(subject_from, src_subject)
+    subject_from = _check_subject_src(subject_from, src_subject, warn_none=True)
     del src
     _validate_type(src_to, (SourceSpaces, None), "src_to")
     _validate_type(subject_to, (str, None), "subject_to")
@@ -823,19 +823,22 @@ def _resample_from_to(img, affine, to_vox_map):
 
 ###############################################################################
 # I/O
-def _check_subject_src(subject, src, name="subject_from", src_name="src"):
+def _check_subject_src(
+    subject, src, name="subject_from", src_name="src", *, warn_none=False
+):
     if isinstance(src, str):
         subject_check = src
     elif src is None:  # assume it's correct although dangerous but unlikely
         subject_check = subject
     else:
         subject_check = src._subject
-        if subject_check is None:
-            warn(
-                "The source space does not contain the subject name, we "
-                "recommend regenerating the source space (and forward / "
-                "inverse if applicable) for better code reliability"
-            )
+        warn_none = True
+    if subject_check is None and warn_none:
+        warn(
+            "The source space does not contain the subject name, we "
+            "recommend regenerating the source space (and forward / "
+            "inverse if applicable) for better code reliability"
+        )
     if subject is None:
         subject = subject_check
     elif subject_check is not None and subject != subject_check:
diff --git a/mne/utils/dataframe.py b/mne/utils/dataframe.py
--- a/mne/utils/dataframe.py
+++ b/mne/utils/dataframe.py
@@ -10,6 +10,7 @@
 
 from ..defaults import _handle_default
 from ._logging import logger, verbose
+from .check import check_version
 
 
 @verbose
@@ -50,9 +51,17 @@ def _convert_times(times, time_format, meas_date=None, first_time=0):
 
 
 def _inplace(df, method, **kwargs):
-    """Handle transition: inplace=True (pandas <1.5) → copy=False (>=1.5)."""
+    # Handle transition: inplace=True (pandas <1.5) → copy=False (>=1.5)
+    # and 3.0 warning:
+    # E   DeprecationWarning: The copy keyword is deprecated and will be removed in a
+    #     future version. Copy-on-Write is active in pandas since 3.0 which utilizes a
+    #     lazy copy mechanism that defers copies until necessary. Use .copy() to make
+    #     an eager copy if necessary.
     _meth = getattr(df, method)  # used for set_index() and rename()
-    if "copy" in signature(_meth).parameters:
+
+    if check_version("pandas", "3.0"):
+        return _meth(**kwargs)
+    elif "copy" in signature(_meth).parameters:
         return _meth(**kwargs, copy=False)
     else:
         _meth(**kwargs, inplace=True)


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -20,7 +20,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/setup-python@v5
         with:
-          python-version: '3.11'
+          python-version: '3.12'
       - uses: pre-commit/action@v3.0.1
 
   bandit:
@@ -54,17 +54,16 @@ jobs:
       matrix:
         include:
           - os: ubuntu-latest
-            python: '3.11'
+            python: '3.12'
             kind: pip-pre
           - os: ubuntu-latest
             python: '3.12'
             kind: conda
-          # 3.12 needs https://github.com/conda-forge/dipy-feedstock/pull/50
           - os: macos-14  # arm64
-            python: '3.11'
+            python: '3.12'
             kind: mamba
           - os: macos-latest  # intel
-            python: '3.11'
+            python: '3.12'
             kind: mamba
           - os: windows-latest
             python: '3.10'
diff --git a/mne/export/tests/test_export.py b/mne/export/tests/test_export.py
--- a/mne/export/tests/test_export.py
+++ b/mne/export/tests/test_export.py
@@ -145,7 +145,7 @@ def _create_raw_for_edf_tests(stim_channel_index=None):
 
 
 edfio_mark = pytest.mark.skipif(
-    not _check_edfio_installed(strict=False), reason="edfio not installed"
+    not _check_edfio_installed(strict=False), reason="unsafe use of private module"
 )