Skip to content

feat: Add Context type generators from schema #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
12 changes: 10 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ classifiers = [
]
requires-python = ">=3.9"
dependencies = [
"attrs >=24.1",
"bidsschematools >=1.0",
"universal_pathlib >=0.2.6",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -97,7 +99,10 @@ exclude = ".*"

[tool.ruff]
line-length = 99
extend-exclude = ["_version.py"]
extend-exclude = [
"_version.py",
"tests/data",
]

[tool.ruff.lint]
extend-select = [
Expand Down Expand Up @@ -136,7 +141,10 @@ inline-quotes = "single"

[tool.ruff.lint.extend-per-file-ignores]
"setup.py" = ["D"]
"*/test_*.py" = ["S101"]
"*/test_*.py" = [
"S101",
"D",
]

[tool.ruff.format]
quote-style = "single"
231 changes: 231 additions & 0 deletions src/bids_validator/types/_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"""Utilities for generating validation context classes from a BIDS schema.

For default contexts based on the installed BIDS schema, use the `context` module.
These functions allow generating classes from alternative schemas.

Basic usage:

.. python::

from bids_validator.context_generator import get_schema, load_schema_into_namespace

schema = get_schema('https://bids-specification.readthedocs.io/en/stable/schema.json')
load_schema_into_namespace(schema['meta']['context'], globals(), 'Context')
"""

from __future__ import annotations

import json

import attrs
import bidsschematools as bst
import bidsschematools.schema

from . import _typings as t

LATEST_SCHEMA_URL = 'https://bids-specification.readthedocs.io/en/latest/schema.json'
STABLE_SCHEMA_URL = 'https://bids-specification.readthedocs.io/en/stable/schema.json'


def get_schema(url: str | None = None) -> bst.types.Namespace:
"""Load a BIDS schema from a URL or return the bundled schema if no URL is provided.

This function utilizes the ``universal_pathlib`` package to handle various URL schemes.
To enable non-default functionality, install the required dependencies using::

pip install fsspec[http] # for HTTP/HTTPS URLs

Typically, ``fsspec[<schema>]`` will work for ``<schema>://...`` URLs.

Parameters
----------
url : str | None
The path or URL to load the schema from. If None, the bundled schema is returned.
The strings 'latest' and 'stable' are also accepted as shortcuts.

Returns
-------
dict[str, Any]
The loaded schema as a dictionary.

"""
if url is None:
return bst.schema.load_schema()

if url == 'latest':
url = LATEST_SCHEMA_URL

Check warning on line 56 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L56

Added line #L56 was not covered by tests
elif url == 'stable':
url = STABLE_SCHEMA_URL

Check warning on line 58 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L58

Added line #L58 was not covered by tests

from upath import UPath

Check warning on line 60 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L60

Added line #L60 was not covered by tests

url = UPath(url)

Check warning on line 62 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L62

Added line #L62 was not covered by tests

try:
schema_text = UPath(url).read_text()
except ImportError as e:
message = (

Check warning on line 67 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L64-L67

Added lines #L64 - L67 were not covered by tests
f'Unable to load {url}. This can probably be fixed '
f'by depending on "fsspec[{url.proto}]".'
)
raise ImportError(message) from e

Check warning on line 71 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L71

Added line #L71 was not covered by tests

return bst.types.Namespace(json.loads(schema_text))

Check warning on line 73 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L73

Added line #L73 was not covered by tests


def snake_to_pascal(val: str) -> str:
"""Convert snake_case string to PascalCase."""
return ''.join(sub.capitalize() for sub in val.split('_'))


def typespec_to_type(name: str, typespec: dict[str, t.Any]) -> tuple[type, dict[str, t.Any]]:
"""Convert JSON-schema style specification to type and metadata dictionary."""
tp = typespec.get('type')
if not tp:
raise ValueError(f'Invalid typespec: {json.dumps(typespec)}')

Check warning on line 85 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L85

Added line #L85 was not covered by tests
metadata = {key: typespec[key] for key in ('name', 'description') if key in typespec}
if tp == 'object':
properties = typespec.get('properties')
if properties:
type_ = create_attrs_class(name, properties=properties, metadata=metadata)
else:
type_ = dict[str, t.Any]
elif tp == 'array':
if 'items' in typespec:
subtype, md = typespec_to_type(name, typespec['items'])
else:
subtype = t.Any

Check warning on line 97 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L97

Added line #L97 was not covered by tests
type_ = list[subtype]
else:
type_ = {
'number': float,
'string': str,
'integer': int,
}[tp]
return type_, metadata


def _type_name(tp: type) -> str:
try:
return tp.__name__
except AttributeError:
return str(tp)

Check warning on line 112 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L111-L112

Added lines #L111 - L112 were not covered by tests


def create_attrs_class(
class_name: str,
properties: dict[str, t.Any],
metadata: dict[str, t.Any],
) -> type:
"""Dynamically create an attrs class with the given properties.

Parameters
----------
class_name
The name of the class to create.
properties
A dictionary of property names and their corresponding schema information.
If a nested object is encountered, a nested class is created.
metadata
A short description of the class, included in the docstring.

Returns
-------
cls : type
The dynamically created attrs class.

"""
attributes = {}
for prop_name, prop_info in properties.items():
type_, md = typespec_to_type(prop_name, prop_info)
attributes[prop_name] = attrs.field(
type=type_, repr=prop_name != 'schema', default=None, metadata=md
)

return attrs.make_class(
snake_to_pascal(class_name),
attributes,
class_body={
'__doc__': f"""\
{metadata.get('description', '')}

attrs data class auto-generated from BIDS schema

Attributes
----------
"""
+ '\n'.join(
f'{k}: {_type_name(v.type)}\n\t{v.metadata["description"]}'
for k, v in attributes.items()
),
},
)


def generate_attrs_classes_from_schema(
schema: dict[str, t.Any],
root_class_name: str,
) -> type:
"""Generate attrs classes from a JSON schema.

Parameters
----------
schema : dict[str, Any]
The JSON schema to generate classes from. Must contain a 'properties' field.
root_class_name : str
The name of the root class to create.

Returns
-------
cls : type
The root class created from the schema.

"""
if 'properties' not in schema:
raise ValueError("Invalid schema: 'properties' field is required")

Check warning on line 185 in src/bids_validator/types/_context.py

View check run for this annotation

Codecov / codecov/patch

src/bids_validator/types/_context.py#L185

Added line #L185 was not covered by tests

type_, _ = typespec_to_type(root_class_name, schema)
return type_


def populate_namespace(attrs_class: type, namespace: dict[str, t.Any]) -> None:
"""Populate a namespace with nested attrs classes.

Parameters
----------
attrs_class : type
The root attrs class to add to the namespace.
namespace : dict[str, Any]
The namespace to populate with nested classes.

"""
for attr in attrs.fields(attrs_class):
attr_type = attr.type

if attrs.has(attr_type):
namespace[attr_type.__name__] = attr_type
populate_namespace(attr_type, namespace)


def load_schema_into_namespace(
schema: dict[str, t.Any],
namespace: dict[str, t.Any],
root_class_name: str,
) -> None:
"""Load a JSON schema into a namespace as attrs classes.

Intended to be used with globals() or locals() to create classes in the current module.

Parameters
----------
schema : dict[str, Any]
The JSON schema to load into the namespace.
namespace : dict[str, Any]
The namespace to load the schema into.
root_class_name : str
The name of the root class to create.

"""
attrs_class = generate_attrs_classes_from_schema(schema, root_class_name)
namespace[root_class_name] = attrs_class
populate_namespace(attrs_class, namespace)
19 changes: 19 additions & 0 deletions src/bids_validator/types/_typings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
__all__ = (
'Self',
'TYPE_CHECKING',
'Any',
)

TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Any, Self
else:

def __getattr__(name: str):
if name in __all__:
import typing

return getattr(typing, name)

msg = f'Module {__name__!r} has no attribute {name!r}'
raise AttributeError(msg)
31 changes: 31 additions & 0 deletions src/bids_validator/types/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Validation context for schema-based BIDS validation."""

from ._context import get_schema, load_schema_into_namespace

schema = get_schema()
load_schema_into_namespace(schema.meta.context, globals(), 'Context')


__all__ = [ # noqa: F822
'Context',
'Dataset',
'Subjects',
'Subject',
'Sessions',
'Associations',
'Events',
'Aslcontext',
'M0scan',
'Magnitude',
'Magnitude1',
'Bval',
'Bvec',
'Channels',
'Coordsystem',
'Gzip',
'NiftiHeader',
'DimInfo',
'XyztUnits',
'Ome',
'Tiff',
]
18 changes: 10 additions & 8 deletions src/bids_validator/types/files.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""Types for working with file trees."""

from __future__ import annotations

import os
import posixpath
import stat
from functools import cached_property
from pathlib import Path
from typing import Union

import attrs
from typing_extensions import Self # PY310

from . import _typings as t

__all__ = ('FileTree',)

Expand Down Expand Up @@ -58,7 +60,7 @@ def is_symlink(self) -> bool:
return stat.S_ISLNK(_stat.st_mode)


def as_direntry(obj: os.PathLike) -> Union[os.DirEntry, UserDirEntry]:
def as_direntry(obj: os.PathLike) -> os.DirEntry | UserDirEntry:
"""Convert PathLike into DirEntry-like object."""
if isinstance(obj, os.DirEntry):
return obj
Expand All @@ -69,10 +71,10 @@ def as_direntry(obj: os.PathLike) -> Union[os.DirEntry, UserDirEntry]:
class FileTree:
"""Represent a FileTree with cached metadata."""

direntry: Union[os.DirEntry, UserDirEntry] = attrs.field(repr=False, converter=as_direntry)
parent: Union['FileTree', None] = attrs.field(repr=False, default=None)
direntry: os.DirEntry | UserDirEntry = attrs.field(repr=False, converter=as_direntry)
parent: FileTree | None = attrs.field(repr=False, default=None)
is_dir: bool = attrs.field(default=False)
children: dict[str, 'FileTree'] = attrs.field(repr=False, factory=dict)
children: dict[str, FileTree] = attrs.field(repr=False, factory=dict)
name: str = attrs.field(init=False)

def __attrs_post_init__(self):
Expand All @@ -85,8 +87,8 @@ def __attrs_post_init__(self):
def read_from_filesystem(
cls,
direntry: os.PathLike,
parent: Union['FileTree', None] = None,
) -> Self:
parent: FileTree | None = None,
) -> t.Self:
"""Read a FileTree from the filesystem.

Uses :func:`os.scandir` to walk the directory tree.
Expand Down
7 changes: 7 additions & 0 deletions tests/types/test_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from bids_validator.types import context


def test_imports():
"""Verify that we do not declare attributes that are not generated."""
for name in context.__all__:
assert hasattr(context, name), f'Failed to import {name} from context'
2 changes: 0 additions & 2 deletions tests/types/test_files.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# ruff: noqa: D100

import attrs

from bids_validator.types.files import FileTree
Expand Down
Loading