Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
de28a46
support python 3.14
h-mayorquin Oct 15, 2025
9f308f5
Merge branch 'main' into support_python_314
h-mayorquin Dec 4, 2025
c50943d
Merge branch 'main' into support_python_314
chrishalcrow Dec 10, 2025
15b5ade
Merge branch 'main' into support_python_314
chrishalcrow Dec 16, 2025
762b659
Remove numcodecs version pin
chrishalcrow Dec 17, 2025
dab1dcc
add uv.lock to gitignore
h-mayorquin Jan 28, 2026
2022aaa
fix windows and mac
h-mayorquin Jan 28, 2026
85a96e0
Merge branch 'main' into support_python_314
alejoe91 Mar 24, 2026
9bbc8e7
Merge branch 'main' into support_python_314
h-mayorquin Oct 6, 2026
6d6c85d
Allow zarr 3 on Python 3.14, reading existing zarr folders and raisin…
h-mayorquin Oct 6, 2026
d6cc8d8
Skip zarr writing in the core tests when zarr>=3 is installed
h-mayorquin Oct 6, 2026
a20fe0e
Read NWB Zarr files written by hdmf-zarr 0.14 under zarr 3
h-mayorquin Oct 6, 2026
da71ed2
Centralize the zarr write skip in a pytest marker and skip IBL and za…
h-mayorquin Oct 7, 2026
9e7e252
Merge branch 'main' into support_python_314
h-mayorquin Oct 7, 2026
716fa07
Mark the waveform tests that write to zarr with requires_zarr_write
h-mayorquin Oct 7, 2026
8664a3d
Skip the zarr part of test_loading_from_analyzer with zarr>=3
h-mayorquin Oct 7, 2026
c380f79
Skip the zarr format in the postprocessing tests with zarr>=3
h-mayorquin Oct 7, 2026
f62b289
Raise the zarr write error at the start of tridesclous2 and lupin whe…
h-mayorquin Oct 7, 2026
d6c53be
Pin git-annex on Linux CI while the latest datalad package is missing
h-mayorquin Oct 7, 2026
a43bad4
Skip the zarr dependent internal sorter and template database tests o…
h-mayorquin Oct 7, 2026
38a897f
make new methods private
h-mayorquin Oct 7, 2026
d5aeaff
Remove the IBL streaming skip on Python 3.14 to test one-api 3.6.0
h-mayorquin Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/all-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.13"] # Lower and higher versions we support
python-version: ["3.10", "3.14"] # Lower and higher versions we support
os: [macos-latest, windows-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -129,7 +129,8 @@ jobs:
run: |
uv pip install --system datalad-installer
if [ ${{ runner.os }} = 'Linux' ]; then
datalad-installer --sudo ok git-annex --method datalad/packages
# TODO: remove the pin once datalad uploads the git-annex 10.20261006 package (its latest-version points to a missing file)
datalad-installer --sudo ok git-annex=10.20260901 --method datalad/packages
elif [ ${{ runner.os }} = 'macOS' ]; then
datalad-installer --sudo ok git-annex --method brew
elif [ ${{ runner.os }} = 'Windows' ]; then
Expand Down
6 changes: 6 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,15 @@ def pytest_collection_modifyitems(config, items):
Marking them in turn allows the tests to be run by using the pytest -m marker_name option.
"""

from spikeinterface.core.core_tools import _is_zarr_write_supported

rootdir = Path(config.rootdir)
modules_location = rootdir / "src" / "spikeinterface"
for item in items:
# TODO: remove once writing to zarr is supported with zarr>=3
if item.get_closest_marker("requires_zarr_write") and not _is_zarr_write_supported():
item.add_marker(pytest.mark.skip(reason="Writing to zarr is not supported yet with zarr>=3"))

if config.getoption("--mp-context") is not None and item.name == "test_global_job_kwargs":
item.add_marker(pytest.mark.skip(reason="--mp-context changes the default job kwargs"))

Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,21 @@ classifiers = [


dependencies = [
"numpy>=2.0.0",
"numpy>=2.0.0;python_version<'3.14'",
"numpy>=2.3.2;python_version>='3.14'",
"threadpoolctl>=3.0.0",
"tqdm",
"zarr>=2.18,<3",
"zarr>=2.18,<3;python_version<'3.14'",
"zarr>=3;python_version>='3.14'", # Zarr writing is not yet supported with zarr 3
# for release we need pypi, so this needs to be commented
"neo @ git+https://github.com/NeuralEnsemble/python-neo.git",
"probeinterface @ git+https://github.com/SpikeInterface/probeinterface.git",
# "neo>=0.14.5",
# "probeinterface>=0.4.0",
"packaging",
"pydantic",
"numcodecs<0.16.0", # For supporting zarr < 3
"numcodecs<0.16.0;python_version<'3.14'", # For supporting zarr < 3
"numcodecs>=0.16.5;python_version>='3.14'",
]

[build-system]
Expand Down Expand Up @@ -364,6 +367,7 @@ markers = [
"widgets",
"sortingcomponents",
"streaming_extractors: extractors that require streaming such as ross and fsspec",
"requires_zarr_write: tests that write to zarr, skipped with zarr>=3 where writing is not supported yet",
]
filterwarnings =[
'ignore:.*distutils Version classes are deprecated.*:DeprecationWarning',
Expand Down
21 changes: 21 additions & 0 deletions src/spikeinterface/core/core_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,27 @@ def measure_memory_allocation(measure_in_process: bool = True) -> float:
return memory


def _is_zarr_write_supported() -> bool:
"""
Whether writing to zarr is supported with the installed zarr, which is not yet the case for zarr>=3.
"""
return int(zarr.__version__.split(".")[0]) < 3


def _check_zarr_write_is_supported() -> None:
"""
Raise an informative error when writing to zarr with zarr>=3, which is not supported yet.

zarr>=3 is what gets installed on Python 3.14, where reading existing zarr folders works but writing does not.
"""
if not _is_zarr_write_supported():
raise NotImplementedError(
f"Writing to zarr is not supported yet with zarr {zarr.__version__}, which is the version installed "
"on Python 3.14. Use Python 3.13 or lower to save in zarr format, or save in another format such as "
"'binary_folder'."
)


def is_path_remote(path: str | Path) -> bool:
"""
Returns True if the path is a remote path (e.g., s3:// or gcs://).
Expand Down
3 changes: 2 additions & 1 deletion src/spikeinterface/core/node_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from spikeinterface.core import BaseRecording, get_chunk_with_margin
from spikeinterface.core.job_tools import TimeSeriesChunkExecutor, fix_job_kwargs, _shared_job_kwargs_doc
from spikeinterface.core import get_channel_distances
from spikeinterface.core.core_tools import ms_to_samples, samples_to_ms
from spikeinterface.core.core_tools import ms_to_samples, samples_to_ms, _check_zarr_write_is_supported


class PipelineNode:
Expand Down Expand Up @@ -1044,6 +1044,7 @@ def __init__(

from spikeinterface.core.zarrextractors import get_default_zarr_compressor

_check_zarr_write_is_supported()
if compressor == "default":
compressor = get_default_zarr_compressor()
self.compressor = compressor
Expand Down
4 changes: 4 additions & 0 deletions src/spikeinterface/core/sortinganalyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
retrieve_importing_provenance,
is_path_remote,
clean_zarr_folder_name,
_check_zarr_write_is_supported,
)
from .sorting_tools import (
generate_unit_ids_for_merge_group,
Expand Down Expand Up @@ -1013,6 +1014,8 @@ def load_from_binary_folder(

def _get_zarr_root(self, mode="r+"):
assert mode in ("r+", "a", "r"), "mode must be 'r+', 'a' or 'r'"
if mode != "r":
_check_zarr_write_is_supported()

storage_options = self._backend_options.get("storage_options", {})
zarr_root = super_zarr_open(self.folder, mode=mode, storage_options=storage_options)
Expand All @@ -1034,6 +1037,7 @@ def create_zarr(
) -> "SortingAnalyzer":
from .zarrextractors import add_sorting_to_zarr_group, ZarrSortingExtractor

_check_zarr_write_is_supported()
is_remote = is_path_remote(folder)
if not is_remote:
folder = clean_zarr_folder_name(folder)
Expand Down
7 changes: 7 additions & 0 deletions src/spikeinterface/core/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ def add_templates_to_zarr_group(self, zarr_group: "zarr.Group") -> None:
to optimize read/write operations for individual units.
"""

from .core_tools import _check_zarr_write_is_supported

_check_zarr_write_is_supported()

# Saves one chunk per unit
arrays_chunk = (1, None, None)
zarr_group.create_dataset("templates_array", data=self.templates_array, chunks=arrays_chunk)
Expand Down Expand Up @@ -353,6 +357,9 @@ def to_zarr(self, folder_path: str | Path) -> None:
"""
import zarr

from .core_tools import _check_zarr_write_is_supported

_check_zarr_write_is_supported()
zarr_group = zarr.open_group(folder_path, mode="w")

self.add_templates_to_zarr_group(zarr_group)
Expand Down
22 changes: 17 additions & 5 deletions src/spikeinterface/core/tests/test_analyzer_extension_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from spikeinterface.core.sortinganalyzer import _extension_children, _get_children_dependencies

import numpy as np
from spikeinterface.core.core_tools import _is_zarr_write_supported

analyzer_formats = ("memory", "binary_folder", "zarr") if _is_zarr_write_supported() else ("memory", "binary_folder")


def get_sorting_analyzer(cache_folder, format="memory", sparse=True):
Expand Down Expand Up @@ -51,7 +54,7 @@ def get_sorting_analyzer(cache_folder, format="memory", sparse=True):

def _check_result_extension(sorting_analyzer, extension_name, cache_folder):
# select unit_ids to several format
for format in ("memory", "binary_folder", "zarr"):
for format in analyzer_formats:
# for format in ("memory", ):
if format != "memory":
if format == "zarr":
Expand All @@ -72,7 +75,9 @@ def _check_result_extension(sorting_analyzer, extension_name, cache_folder):
# print(k, arr.shape)


@pytest.mark.parametrize("format", ["memory", "binary_folder", "zarr"])
@pytest.mark.parametrize(
"format", ["memory", "binary_folder", pytest.param("zarr", marks=pytest.mark.requires_zarr_write)]
)
@pytest.mark.parametrize(
"sparse",
[
Expand Down Expand Up @@ -102,7 +107,9 @@ def test_ComputeRandomSpikes(format, sparse, create_cache_folder):
_check_result_extension(sorting_analyzer, "random_spikes", cache_folder)


@pytest.mark.parametrize("format", ["memory", "binary_folder", "zarr"])
@pytest.mark.parametrize(
"format", ["memory", "binary_folder", pytest.param("zarr", marks=pytest.mark.requires_zarr_write)]
)
@pytest.mark.parametrize("sparse", [True, False])
def test_ComputeWaveforms(format, sparse, create_cache_folder):
cache_folder = create_cache_folder
Expand All @@ -115,6 +122,7 @@ def test_ComputeWaveforms(format, sparse, create_cache_folder):
_check_result_extension(sorting_analyzer, "waveforms", cache_folder)


@pytest.mark.requires_zarr_write
@pytest.mark.parametrize("sparse", [False, True])
def test_ComputeWaveforms_consistent_across_formats(create_cache_folder, sparse):
# Computing waveforms (and templates) on memory / binary_folder / zarr analyzers with the same
Expand Down Expand Up @@ -170,7 +178,9 @@ def test_ComputeWaveforms_consistent_across_formats(create_cache_folder, sparse)
assert np.array_equal(zarr_waveforms, wfs_mem)


@pytest.mark.parametrize("format", ["memory", "binary_folder", "zarr"])
@pytest.mark.parametrize(
"format", ["memory", "binary_folder", pytest.param("zarr", marks=pytest.mark.requires_zarr_write)]
)
@pytest.mark.parametrize("sparse", [True, False])
def test_ComputeTemplates(format, sparse, create_cache_folder):
cache_folder = create_cache_folder
Expand Down Expand Up @@ -259,7 +269,9 @@ def test_ComputeTemplates(format, sparse, create_cache_folder):
_check_result_extension(sorting_analyzer, "templates", cache_folder)


@pytest.mark.parametrize("format", ["memory", "binary_folder", "zarr"])
@pytest.mark.parametrize(
"format", ["memory", "binary_folder", pytest.param("zarr", marks=pytest.mark.requires_zarr_write)]
)
@pytest.mark.parametrize("sparse", [True, False])
def test_ComputeNoiseLevels(format, sparse, create_cache_folder):
cache_folder = create_cache_folder
Expand Down
55 changes: 29 additions & 26 deletions src/spikeinterface/core/tests/test_baserecording.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from spikeinterface.core.testing import check_recordings_equal

from spikeinterface.core import generate_recording
from spikeinterface.core.core_tools import _is_zarr_write_supported


def test_BaseRecording(create_cache_folder):
Expand Down Expand Up @@ -364,32 +365,34 @@ def test_BaseRecording(create_cache_folder):
rec_2d = rec_3d.planarize(axes="zy")
assert np.allclose(rec_2d.get_channel_locations(), locations_3d[:, [2, 1]])

# test save to zarr
compressor = get_default_zarr_compressor()
rec_zarr = rec2.save(format="zarr", folder=cache_folder / "recording", compressor=compressor)
rec_zarr_loaded = load(cache_folder / "recording.zarr")
# annotations is False because Zarr adds compression ratios
check_recordings_equal(rec2, rec_zarr, return_in_uV=False, check_annotations=False, check_properties=True)
check_recordings_equal(
rec_zarr, rec_zarr_loaded, return_in_uV=False, check_annotations=False, check_properties=True
)
for annotation_name in rec2.get_annotation_keys():
assert rec2.get_annotation(annotation_name) == rec_zarr.get_annotation(annotation_name)
assert rec2.get_annotation(annotation_name) == rec_zarr_loaded.get_annotation(annotation_name)

rec_zarr2 = rec2.save(
format="zarr", folder=cache_folder / "recording_channel_chunk", compressor=compressor, channel_chunk_size=2
)
rec_zarr2_loaded = load(cache_folder / "recording_channel_chunk.zarr")

# annotations is False because Zarr adds compression ratios
check_recordings_equal(rec2, rec_zarr2, return_in_uV=False, check_annotations=False, check_properties=True)
check_recordings_equal(
rec_zarr2, rec_zarr2_loaded, return_in_uV=False, check_annotations=False, check_properties=True
)
for annotation_name in rec2.get_annotation_keys():
assert rec2.get_annotation(annotation_name) == rec_zarr2.get_annotation(annotation_name)
assert rec2.get_annotation(annotation_name) == rec_zarr2_loaded.get_annotation(annotation_name)
# TODO: remove once writing to zarr is supported with zarr>=3
if _is_zarr_write_supported():
# test save to zarr
compressor = get_default_zarr_compressor()
rec_zarr = rec2.save(format="zarr", folder=cache_folder / "recording", compressor=compressor)
rec_zarr_loaded = load(cache_folder / "recording.zarr")
# annotations is False because Zarr adds compression ratios
check_recordings_equal(rec2, rec_zarr, return_in_uV=False, check_annotations=False, check_properties=True)
check_recordings_equal(
rec_zarr, rec_zarr_loaded, return_in_uV=False, check_annotations=False, check_properties=True
)
for annotation_name in rec2.get_annotation_keys():
assert rec2.get_annotation(annotation_name) == rec_zarr.get_annotation(annotation_name)
assert rec2.get_annotation(annotation_name) == rec_zarr_loaded.get_annotation(annotation_name)

rec_zarr2 = rec2.save(
format="zarr", folder=cache_folder / "recording_channel_chunk", compressor=compressor, channel_chunk_size=2
)
rec_zarr2_loaded = load(cache_folder / "recording_channel_chunk.zarr")

# annotations is False because Zarr adds compression ratios
check_recordings_equal(rec2, rec_zarr2, return_in_uV=False, check_annotations=False, check_properties=True)
check_recordings_equal(
rec_zarr2, rec_zarr2_loaded, return_in_uV=False, check_annotations=False, check_properties=True
)
for annotation_name in rec2.get_annotation_keys():
assert rec2.get_annotation(annotation_name) == rec_zarr2.get_annotation(annotation_name)
assert rec2.get_annotation(annotation_name) == rec_zarr2_loaded.get_annotation(annotation_name)


def test_json_pickle_equivalence(create_cache_folder):
Expand Down
23 changes: 13 additions & 10 deletions src/spikeinterface/core/tests/test_basesorting.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from spikeinterface.core.base import BaseExtractor, minimum_spike_dtype, unit_period_dtype
from spikeinterface.core.basesorting import LEXSORT_UNIT_COMPACT
from spikeinterface.core.testing import check_sorted_arrays_equal, check_sortings_equal
from spikeinterface.core.core_tools import _is_zarr_write_supported


def test_BaseSorting(create_cache_folder):
Expand Down Expand Up @@ -143,16 +144,18 @@ def test_BaseSorting(create_cache_folder):
del sorting6
del sorting5

# test save to zarr
# compressor = get_default_zarr_compressor()
sorting_zarr = sorting.save(format="zarr", folder=cache_folder / "sorting.zarr")
sorting_zarr_loaded = load(cache_folder / "sorting.zarr")
# annotations is False because Zarr adds compression ratios
check_sortings_equal(sorting, sorting_zarr, check_annotations=False, check_properties=True)
check_sortings_equal(sorting_zarr, sorting_zarr_loaded, check_annotations=False, check_properties=True)
for annotation_name in sorting.get_annotation_keys():
assert sorting.get_annotation(annotation_name) == sorting_zarr.get_annotation(annotation_name)
assert sorting.get_annotation(annotation_name) == sorting_zarr_loaded.get_annotation(annotation_name)
# TODO: remove once writing to zarr is supported with zarr>=3
if _is_zarr_write_supported():
# test save to zarr
# compressor = get_default_zarr_compressor()
sorting_zarr = sorting.save(format="zarr", folder=cache_folder / "sorting.zarr")
sorting_zarr_loaded = load(cache_folder / "sorting.zarr")
# annotations is False because Zarr adds compression ratios
check_sortings_equal(sorting, sorting_zarr, check_annotations=False, check_properties=True)
check_sortings_equal(sorting_zarr, sorting_zarr_loaded, check_annotations=False, check_properties=True)
for annotation_name in sorting.get_annotation_keys():
assert sorting.get_annotation(annotation_name) == sorting_zarr.get_annotation(annotation_name)
assert sorting.get_annotation(annotation_name) == sorting_zarr_loaded.get_annotation(annotation_name)


def _make_sorting_with_shuffled_ties(num_units, num_segments, seed=42):
Expand Down
12 changes: 9 additions & 3 deletions src/spikeinterface/core/tests/test_loading.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
import zarr

import numpy as np
from spikeinterface import (
Expand Down Expand Up @@ -89,7 +90,7 @@ def generate_motion_object():
return motion


@pytest.mark.parametrize("output_format", ["binary", "zarr"])
@pytest.mark.parametrize("output_format", ["binary", pytest.param("zarr", marks=pytest.mark.requires_zarr_write)])
def test_load_binary_recording(generate_recording_sorting, tmp_path, output_format):
rec, _ = generate_recording_sorting
_ = rec.save(folder=tmp_path / "test_recording", format=output_format, overwrite=True)
Expand All @@ -102,7 +103,7 @@ def test_load_binary_recording(generate_recording_sorting, tmp_path, output_form
check_recordings_equal(rec, rec_loaded)


@pytest.mark.parametrize("output_format", ["numpy_folder", "zarr"])
@pytest.mark.parametrize("output_format", ["numpy_folder", pytest.param("zarr", marks=pytest.mark.requires_zarr_write)])
def test_load_binary_sorting(generate_recording_sorting, tmp_path, output_format):
_, sort = generate_recording_sorting
_ = sort.save(folder=tmp_path / "test_sorting", format=output_format, overwrite=True)
Expand Down Expand Up @@ -140,7 +141,9 @@ def test_load_ext_extractors(generate_recording_sorting, tmp_path, extension):
check_sortings_equal(sort, sort_loaded, check_properties=False)


@pytest.mark.parametrize("output_format", ["binary_folder", "zarr"])
@pytest.mark.parametrize(
"output_format", ["binary_folder", pytest.param("zarr", marks=pytest.mark.requires_zarr_write)]
)
def test_load_sorting_analyzer(generate_sorting_analyzer, tmp_path, output_format):
analyzer = generate_sorting_analyzer
_ = analyzer.save_as(folder=tmp_path / "analyzer", format=output_format)
Expand All @@ -157,6 +160,7 @@ def test_load_sorting_analyzer(generate_sorting_analyzer, tmp_path, output_forma
assert ext in analyzer_loaded.extensions


@pytest.mark.requires_zarr_write
def test_load_templates(tmp_path, generate_templates_object):
templates = generate_templates_object
templates_dict = templates.to_dict()
Expand Down Expand Up @@ -212,6 +216,8 @@ def test_remote_recording():

@pytest.mark.streaming_extractors
@pytest.mark.skipif(not HAVE_S3, reason="s3fs not installed")
# TODO: remove once streaming zarr is supported with zarr>=3
@pytest.mark.skipif(int(zarr.__version__.split(".")[0]) >= 3, reason="Streaming zarr is not supported yet with zarr>=3")
def test_remote_analyzer():
s3_path = "s3://spikeinterface-sorting-analyzer-test/analyzer_remote_test.zarr/"
analyzer = load(s3_path)
Expand Down
Loading
Loading