Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Removals, Deprecations and Changes

## Bug Fixes
* Fixed `get_json_schema_from_method_signature` to resolve PEP 563 string annotations (from `from __future__ import annotations`) before passing them to pydantic. This affected any interface defined in a module with deferred annotations (e.g. `MiniscopeConverter`, or external subclasses from SpikeInterface). [PR #1670](https://github.com/catalystneuro/neuroconv/pull/1670)

## Features

Expand Down
18 changes: 17 additions & 1 deletion src/neuroconv/utils/json_schema.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import collections.abc
import inspect
import json
import typing
import warnings
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -159,6 +160,17 @@ def get_json_schema_from_method_signature(method: Callable, exclude: list[str] |
parameters = signature.parameters
additional_properties = False
arguments_to_annotations = {}

# Resolve string annotations from PEP 563 (from __future__ import annotations)
# TODO: Remove PEP 563 handling once minimum Python version is 3.14+
# and external consumers no longer use `from __future__ import annotations`
# When a class is passed, inspect.signature uses __init__, so we must too
hints_target = method.__init__ if inspect.isclass(method) else method
try:
type_hints = typing.get_type_hints(hints_target, include_extras=True)
except NameError:
type_hints = {}

for argument_name in parameters:
if argument_name in exclude:
continue
Expand All @@ -181,7 +193,11 @@ def get_json_schema_from_method_signature(method: Callable, exclude: list[str] |

# Pydantic uses ellipsis for required
pydantic_default = ... if parameter.default is inspect._empty else parameter.default
arguments_to_annotations.update({argument_name: (parameter.annotation, pydantic_default)})
# Only use resolved type hints for string annotations (PEP 563)
annotation = parameter.annotation
if isinstance(annotation, str):
annotation = type_hints.get(argument_name, annotation)
arguments_to_annotations.update({argument_name: (annotation, pydantic_default)})

# The ConfigDict is required to support custom types like NumPy arrays
model = pydantic.create_model(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import inspect
import types
from pathlib import Path
from typing import Literal

Expand Down Expand Up @@ -581,3 +583,49 @@ def test_mock_imaging_interface_schema_with_args_pattern():
}

assert test_json_schema == expected_json_schema


class TestPEP563Annotations:
"""Tests for PEP 563 (from __future__ import annotations) support.

When an external package (e.g. SpikeInterface) defines a BaseDataInterface subclass
in a module with ``from __future__ import annotations``, all type annotations on
``__init__`` become strings at runtime. ``get_source_schema()`` then passes those
strings to pydantic, which cannot resolve them.

PEP 563 is a module-level directive, so we dynamically create a module
with it enabled to keep the tests self-contained.
"""

@pytest.fixture()
def pep563_interface(self):
"""Simulate an external interface defined in a module with PEP 563."""
source = (
"from __future__ import annotations\n"
"from pydantic import DirectoryPath\n"
"from neuroconv import BaseDataInterface\n"
"\n"
"class ExternalInterface(BaseDataInterface):\n"
" display_name = 'External'\n"
" def __init__(self, folder_path: DirectoryPath, verbose: bool = False):\n"
" pass\n"
" def add_to_nwbfile(self, nwbfile, metadata):\n"
" pass\n"
)
code = compile(source, "<external_pep563_module>", "exec")
module = types.ModuleType("_external_pep563_module")
exec(code, module.__dict__)
return module.ExternalInterface

def test_pep563_annotations_are_strings(self, pep563_interface):
"""Verify that PEP 563 stores annotations as strings on the external interface."""
signature = inspect.signature(pep563_interface.__init__)
annotation = signature.parameters["folder_path"].annotation
assert isinstance(annotation, str), "Expected string annotation under PEP 563"
assert annotation == "DirectoryPath"

def test_get_source_schema_from_pep563_interface(self, pep563_interface):
"""Test that get_source_schema works for an external interface using PEP 563."""
source_schema = pep563_interface.get_source_schema()
assert source_schema["properties"]["folder_path"] == {"format": "directory-path", "type": "string"}
assert source_schema["properties"]["verbose"] == {"default": False, "type": "boolean"}
Loading