diff --git a/pyproject.toml b/pyproject.toml index d976209..8eced6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,9 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ + "attrs >=24.1", "bidsschematools >=1.0", + "universal_pathlib >=0.2.6", ] [project.optional-dependencies] @@ -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 = [ @@ -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" diff --git a/src/bids_validator/types/_context.py b/src/bids_validator/types/_context.py new file mode 100644 index 0000000..7f19d97 --- /dev/null +++ b/src/bids_validator/types/_context.py @@ -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[]`` will work for ``://...`` 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 + elif url == 'stable': + url = STABLE_SCHEMA_URL + + from upath import UPath + + url = UPath(url) + + try: + schema_text = UPath(url).read_text() + except ImportError as e: + message = ( + f'Unable to load {url}. This can probably be fixed ' + f'by depending on "fsspec[{url.proto}]".' + ) + raise ImportError(message) from e + + return bst.types.Namespace(json.loads(schema_text)) + + +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)}') + 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 + 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) + + +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") + + 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) diff --git a/src/bids_validator/types/_typings.py b/src/bids_validator/types/_typings.py new file mode 100644 index 0000000..dd42a78 --- /dev/null +++ b/src/bids_validator/types/_typings.py @@ -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) diff --git a/src/bids_validator/types/context.py b/src/bids_validator/types/context.py new file mode 100644 index 0000000..0cfac50 --- /dev/null +++ b/src/bids_validator/types/context.py @@ -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', +] diff --git a/src/bids_validator/types/files.py b/src/bids_validator/types/files.py index 2b4d731..18f04e4 100644 --- a/src/bids_validator/types/files.py +++ b/src/bids_validator/types/files.py @@ -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',) @@ -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 @@ -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): @@ -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. diff --git a/tests/types/test_context.py b/tests/types/test_context.py new file mode 100644 index 0000000..48a33f5 --- /dev/null +++ b/tests/types/test_context.py @@ -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' diff --git a/tests/types/test_files.py b/tests/types/test_files.py index 229994e..fc9d247 100644 --- a/tests/types/test_files.py +++ b/tests/types/test_files.py @@ -1,5 +1,3 @@ -# ruff: noqa: D100 - import attrs from bids_validator.types.files import FileTree