Skip to content

CedRawIO is broken with sonpy >= 1.9.12: the lib namespace no longer exists #1890

Description

@AxelNoun

Summary

neo/rawio/cedrawio.py reaches the sonpy API through sonpy.lib:

import sonpy
self.smrx_file = sonpy.lib.SonFile(sName=str(self.filename), bReadOnly=True)

sonpy 1.9.12 (released 2026-06-09) reorganised its package layout and no longer defines
lib. Every call site in cedrawio.py therefore raises:

AttributeError: module 'sonpy' has no attribute 'lib'

The compiled library itself is fine — it opens and reads .smrx files normally. Only the
namespace moved. SpikeInterface's read_ced is affected transitively.

Reproduction

uv venv --python 3.14 && uv pip install sonpy neo
import sonpy
print(sonpy.__version__ if hasattr(sonpy, "__version__") else "n/a")
sonpy.lib.SonFile          # AttributeError: module 'sonpy' has no attribute 'lib'

from neo.rawio import CedRawIO
CedRawIO(filename="m365_1sec.smrx").parse_header()   # same AttributeError

Evidence

Tested with neo 0.14.5 and spikeinterface 0.104.8.

OS Python sonpy resolved wheel tag import sonpy sonpy.lib? CedRawIO.parse_header native read of a real .smrx
Windows 3.10 – 3.14 1.9.12 cp3xx-win_amd64 OK absent AttributeError works
Linux x86_64 3.14 1.9.12 cp314-manylinux_2_39_x86_64 OK absent AttributeError not tested
Linux x86_64 3.13 1.9.5 (fallback) py3-none-any ImportError n/a n/a n/a
macOS not tested not tested not tested not tested

On Windows the file read succeeds through the new namespace: GetOpenError() == 0,
MaxChannels() == 101, GetVersion() == 257, plausible timebase. So this is purely an
import-path problem, not a functional regression in sonpy's reader.

The Linux 3.13 row is a different failure mode worth noting separately: no 1.9.12 wheel
exists for cp310–cp313 outside Windows, the resolver falls back to sonpy 1.9.5, and its
bundled sonpy/linux/sonpy.so targets the CPython 3.9 ABI —
ImportError: undefined symbol: _PyThreadState_UncheckedGet.

Root cause

sonpy's packaging changed shape between 1.9.5 and 1.9.12.

Every release from 1.1 through 1.9.5 shipped a single py3-none-any wheel with a 331-byte
__init__.py that dispatched per platform and bound the binary to lib:

if system() == 'Windows':
    import sonpy.amd64.sonpy as lib
elif system() == 'Darwin':
    import sonpy.darwin.sonpy as lib
elif system() == 'Linux':
    import sonpy.linux.sonpy as lib

1.9.12 ships per-interpreter binary wheels instead, and lib is gone from all eight of them.
The replacement __init__.py is inconsistent across platforms in the same release:

wheel __init__.py content
cp39cp314 win_amd64 103 bytes docstring + from .sonpy import * + __version__
cp314 macosx universal2 21 bytes from .sonpy import *
cp314 manylinux_2_39_x86_64 0 bytes empty

The empty Linux __init__.py looks like a build-side mistake and is worth reporting to CED
separately, but it matters here: on Linux, neither sonpy.lib nor sonpy.SonFile resolves.
Only sonpy.sonpy.SonFile does. Any fix therefore needs three fallbacks, not two.

Why pinning sonpy < 1.9.12 will not work

The version metadata makes the two mutually exclusive:

  • neo 0.14.5 declares Requires-Python: >=3.10
  • sonpy 1.9.5 declares Requires-Python: >=3.9, <3.10

There is no sonpy release before 1.9.12 that is installable on any Python that Neo supports.
CED support has effectively been unreachable since Neo dropped 3.9; 1.9.12 made sonpy
installable again on modern Python, which is what surfaced the namespace mismatch.

Proposed fix

Resolve the namespace once, then use it at the three call sites. Full diff at the bottom
of this issue; it applies cleanly to main (35cbce7) and touches only cedrawio.py.

def _get_sonpy_namespace():
    import importlib
    import sonpy

    for candidate in (
        lambda: sonpy.lib,                                    # <= 1.9.5
        lambda: sonpy,                                        # >= 1.9.12, Windows / macOS
        lambda: importlib.import_module("sonpy.sonpy"),       # >= 1.9.12, Linux
    ):
        try:
            namespace = candidate()
        except (AttributeError, ImportError):
            continue
        if hasattr(namespace, "SonFile"):
            return namespace
    raise ImportError(...)

Verified on Linux / Python 3.14 / sonpy 1.9.12: the helper resolves to sonpy.sonpy,
SonFile, DataType.Adc, DataType.AdcMark and MarkerFilter are all reachable, and
CedRawIO.parse_header() runs to completion instead of raising.

The existing skip guard in neo/test/iotest/test_cedio.py also needs updating — it imports
sonpy.amd64.sonpy / sonpy.linux.sonpy, so HAVE_SONPY is silently False with 1.9.12
and the CED tests never run.

Not covered here

  • macOS has not been tested. It is the one platform where the 1.9.12 wheel exists only for
    cp314, so 3.10–3.13 would hit the fallback path instead. Happy to run it if someone has
    a machine available.
  • Whether a full read (not just parse_header) returns identical data before and after the
    change has not been checked on Linux for lack of a test file.

Side observation

CedRawIO.parse_header() completes without error on a nonexistent filename, yielding zero
channels — sonpy returns an error code rather than raising, and it is not checked. Minor and
unrelated, but possibly worth its own issue.

Patch

Against main (35cbce7). Only neo/rawio/cedrawio.py is modified.

--- a/neo/rawio/cedrawio.py
+++ b/neo/rawio/cedrawio.py
@@ -31,6 +31,42 @@
 )
 
 
+def _get_sonpy_namespace():
+    """Return the sonpy module namespace that exposes ``SonFile``.
+
+    The layout of the sonpy package changed in 1.9.12:
+
+    * <= 1.9.5  ``sonpy/__init__.py`` binds the platform binary to ``lib``
+    * >= 1.9.12 the top level does ``from .sonpy import *`` (Windows, macOS),
+      but the Linux wheel ships an empty ``__init__.py``, so only the
+      ``sonpy.sonpy`` extension module is populated.
+
+    Probing all three keeps both old and new installs working.
+    """
+    import importlib
+
+    import sonpy
+
+    candidates = (
+        lambda: sonpy.lib,
+        lambda: sonpy,
+        lambda: importlib.import_module("sonpy.sonpy"),
+    )
+    for candidate in candidates:
+        try:
+            namespace = candidate()
+        except (AttributeError, ImportError):
+            continue
+        if hasattr(namespace, "SonFile"):
+            return namespace
+
+    raise ImportError(
+        "The installed sonpy package exposes no 'SonFile' symbol "
+        "(tried sonpy.lib, sonpy and sonpy.sonpy). "
+        "See https://pypi.org/project/sonpy/ for supported versions."
+    )
+
+
 class CedRawIO(BaseRawIO):
     """
     Class for reading data from CED (Cambridge Electronic Design) spike2.
@@ -67,9 +103,9 @@
         return self.filename
 
     def _parse_header(self):
-        import sonpy
+        sonpy_ns = _get_sonpy_namespace()
 
-        self.smrx_file = sonpy.lib.SonFile(sName=str(self.filename), bReadOnly=True)
+        self.smrx_file = sonpy_ns.SonFile(sName=str(self.filename), bReadOnly=True)
         smrx = self.smrx_file
 
         self._time_base = smrx.GetTimeBase()
@@ -82,7 +118,7 @@
         for chan_ind in range(smrx.MaxChannels()):
             chan_type = smrx.ChannelType(chan_ind)
             chan_id = str(chan_ind)
-            if chan_type == sonpy.lib.DataType.Adc:
+            if chan_type == sonpy_ns.DataType.Adc:
                 physical_chan = smrx.PhysicalChannel(chan_ind)
                 divide = smrx.ChannelDivide(chan_ind)
                 if self.take_ideal_sampling_rate:
@@ -105,13 +141,13 @@
                 buffer_id = ""
                 signal_channels.append((ch_name, chan_id, sr, dtype, units, gain, offset, stream_id, buffer_id))
 
-            elif chan_type == sonpy.lib.DataType.AdcMark:
+            elif chan_type == sonpy_ns.DataType.AdcMark:
                 # spike and waveforms : only spike times is used here
                 ch_name = smrx.GetChannelTitle(chan_ind)
                 first_time = smrx.FirstTime(chan_ind, 0, max_time)
                 max_time = smrx.ChannelMaxTime(chan_ind)
                 divide = smrx.ChannelDivide(chan_ind)
-                # here we don't use filter (sonpy.lib.MarkerFilter()) so we get all marker
+                # here we don't use filter (sonpy_ns.MarkerFilter()) so we get all marker
                 wave_marks = smrx.ReadWaveMarks(chan_ind, int(max_time / divide), 0, max_time)
 
                 # here we load in memory all spike once because the access is really slow

I have this ready as a PR if the approach looks right to you — I did not want to open one
before checking whether you would rather raise a clear error than probe namespaces, and
whether the test_cedio.py skip guard should move in the same change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions