Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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