Skip to content
Merged
3 changes: 2 additions & 1 deletion tools/schemacode/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ requires-python = ">=3.9"
dependencies = [
"click",
"pyyaml",
"jsonschema"
"jsonschema[format]"
]
classifiers = [
"Development Status :: 4 - Beta",
Expand All @@ -37,6 +37,7 @@ render = [
]
tests = [
"bidsschematools[expressions,render]",
"check-jsonschema",
"codecov",
"coverage[toml]",
"flake8",
Expand Down
15 changes: 11 additions & 4 deletions tools/schemacode/src/bidsschematools/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import tempfile
from collections.abc import Iterable, Mapping
from copy import deepcopy
from functools import lru_cache
from functools import cache, lru_cache
from importlib.resources import files

from jsonschema import ValidationError, validate
from jsonschema import ValidationError
from jsonschema.protocols import Validator as JsonschemaValidator

from . import __bids_version__, __version__, utils
from .types import Namespace
Expand Down Expand Up @@ -100,6 +101,13 @@ def _dereference(namespace, base_schema):
struct.update({**target, **struct})


@cache
def get_schema_validator() -> JsonschemaValidator:
"""Get the jsonschema validator for validating BIDS schemas."""
metaschema = json.loads(files("bidsschematools.data").joinpath("metaschema.json").read_text())
return utils.jsonschema_validator(metaschema, check_format=True)


def dereference(namespace, inplace=True):
"""Replace references in namespace with the contents of the referred object.

Expand Down Expand Up @@ -293,12 +301,11 @@ def filter_schema(schema, **kwargs):

def validate_schema(schema: Namespace):
"""Validate a schema against the BIDS metaschema."""
metaschema = json.loads(files("bidsschematools.data").joinpath("metaschema.json").read_text())

# validate is put in this try/except clause because the error is sometimes too long to
# print in the terminal
try:
validate(instance=schema.to_dict(), schema=metaschema)
get_schema_validator().validate(instance=schema.to_dict())
except ValidationError as e:
with tempfile.NamedTemporaryFile(
prefix="schema_error_", suffix=".txt", delete=False, mode="w+"
Expand Down
38 changes: 38 additions & 0 deletions tools/schemacode/src/bidsschematools/tests/test_schema.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Tests for the bidsschematools package."""

import json
import os
import subprocess
from collections.abc import Mapping
from importlib.resources import files

import pytest
from jsonschema.exceptions import ValidationError
Expand Down Expand Up @@ -365,6 +368,41 @@ def test_valid_schema():
schema.validate_schema(namespace)


@pytest.mark.parametrize("regex_variant", ["default", "nonunicode", "python"])
def test_valid_schema_with_check_jsonschema(tmp_path, regex_variant):
"""
Test that the BIDS schema is valid against the metaschema when validation is done
using the `check-jsonschema` CLI
"""
bids_schema = schema.load_schema().to_dict()
metaschema_path = str(files("bidsschematools.data").joinpath("metaschema.json"))

# Save BIDS schema to a temporary file
bids_schema_path = tmp_path / "bids_schema.json"
bids_schema_path.write_text(json.dumps(bids_schema))

# Invoke the check-jsonschema to validate the BIDS schema
try:
subprocess.run(
[
"check-jsonschema",
"--regex-variant",
regex_variant,
"--schemafile",
metaschema_path,
str(bids_schema_path),
],
stdout=subprocess.PIPE, # Capture stdout
stderr=subprocess.STDOUT, # Set stderr to into stdout
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
pytest.fail(
f"check-jsonschema failed with code {e.returncode}:\n{e.stdout}", pytrace=False
)


def test_add_legal_field():
"""Test that adding a legal field does not raise an error."""
namespace = schema.load_schema()
Expand Down
166 changes: 166 additions & 0 deletions tools/schemacode/src/bidsschematools/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
from contextlib import nullcontext
from typing import cast

import pytest
from jsonschema.exceptions import SchemaError, ValidationError
from jsonschema.protocols import Validator as JsonschemaValidator
from jsonschema.validators import Draft7Validator, Draft202012Validator

from bidsschematools.utils import jsonschema_validator


@pytest.fixture
def draft7_schema() -> dict:
"""
A minimal valid Draft 7 schema requiring a 'name' property of type 'string'.
"""
return {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
}


@pytest.fixture
def draft202012_schema() -> dict:
"""
A minimal valid Draft 2020-12 schema requiring a 'title' property of type 'string'.
"""
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
}


@pytest.fixture
def draft202012_format_schema() -> dict:
"""
Draft 2020-12 schema that includes a 'format' requirement (e.g., 'email').
Used to test the 'check_format' parameter.
"""
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {"email": {"type": "string", "format": "email"}},
"required": ["email"],
}


@pytest.fixture
def schema_no_dollar_schema() -> dict:
"""
Schema that lacks the '$schema' property altogether.
Used to test that 'default_cls' is applied.
"""
return {
"type": "object",
"properties": {"foo": {"type": "string"}},
"required": ["foo"],
}


class TestJsonschemaValidator:
@pytest.mark.parametrize(
("fixture_name", "expected_validator_cls"),
[
pytest.param("draft202012_format_schema", Draft202012Validator, id="Draft202012"),
pytest.param("draft7_schema", Draft7Validator, id="Draft7"),
],
)
@pytest.mark.parametrize("check_format", [True, False])
def test_set_by_dollar_schema(
self,
request: pytest.FixtureRequest,
fixture_name: str,
expected_validator_cls: type,
check_format: bool,
) -> None:
"""
Test that the correct validator class is returned for different '$schema' values
"""
# Dynamically retrieve the appropriate fixture schema based on fixture_name
schema = request.getfixturevalue(fixture_name)

validator = jsonschema_validator(schema, check_format=check_format)

assert isinstance(validator, expected_validator_cls)

@pytest.mark.parametrize(
("check_format", "instance", "expect_raises"),
[
(True, {"email": "[email protected]"}, False),
(True, {"email": "not-an-email"}, True),
(False, {"email": "not-an-email"}, False),
],
ids=[
"check_format=True, valid email",
"check_format=True, invalid email",
"check_format=False, invalid email",
],
)
def test_check_format_email_scenarios(
self,
draft202012_format_schema: dict,
check_format: bool,
instance: dict,
expect_raises: bool,
) -> None:
"""
Parametrized test for check_format usage on valid/invalid email addresses under
Draft202012Validator.
"""
validator = jsonschema_validator(draft202012_format_schema, check_format=check_format)

# If expect_raises is True, we use pytest.raises(ValidationError)
# Otherwise, we enter a no-op context
ctx = pytest.raises(ValidationError) if expect_raises else nullcontext()

with ctx:
validator.validate(instance) # Should raise or not raise as parametrized

@pytest.mark.parametrize(
("schema_fixture", "expected_validator_cls"),
[
# Scenario 1: no $schema => we expect the default_cls=Draft7Validator is used
pytest.param("schema_no_dollar_schema", Draft7Validator, id="no-$schema"),
# Scenario 2: has $schema => draft 2020-12 overrides the default_cls
pytest.param("draft202012_schema", Draft202012Validator, id="with-$schema"),
],
)
def test_default_cls(
self,
request: pytest.FixtureRequest,
schema_fixture: str,
expected_validator_cls: type,
) -> None:
"""
If the schema has no '$schema' property, and we provide a 'default_cls',
the returned validator should be an instance of that class.

If the schema *does* have '$schema', then the default_cls is ignored, and
the validator class is inferred from the schema's '$schema' field.
"""
# Dynamically grab whichever fixture is specified by schema_fixture:
schema = request.getfixturevalue(schema_fixture)

# Provide default_cls=Draft7Validator
validator = jsonschema_validator(
schema,
check_format=False,
default_cls=cast(type[JsonschemaValidator], Draft7Validator),
)
assert isinstance(validator, expected_validator_cls)

def test_invalid_schema_raises_schema_error(self) -> None:
"""
Provide an invalid schema, ensuring that 'SchemaError' is raised.
"""
invalid_schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": 123, # 'type' must be string/array, so this is invalid
}
with pytest.raises(SchemaError):
jsonschema_validator(invalid_schema, check_format=False)
53 changes: 53 additions & 0 deletions tools/schemacode/src/bidsschematools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import logging
import os
import sys
from typing import Any, Optional

from jsonschema.protocols import Validator as JsonschemaValidator
from jsonschema.validators import validator_for

from . import data

Expand Down Expand Up @@ -82,3 +86,52 @@ def set_logger_level(lgr, level):
lgr.warning("Do not know how to treat loglevel %s" % level)
return
lgr.setLevel(level)


def jsonschema_validator(
schema: dict[str, Any],
*,
check_format: bool,
default_cls: Optional[type[JsonschemaValidator]] = None,
) -> JsonschemaValidator:
"""
Create a jsonschema validator appropriate for validating instances against a given
JSON schema

Parameters
----------
schema : dict[str, Any]
The JSON schema to validate against
check_format : bool
Indicates whether to check the format against format specifications in the
schema
default_cls : type[JsonschemaValidator] or None, optional
The default JSON schema validator class to use to create the
validator should the appropriate validator class cannot be determined based on
the schema (by assessing the `$schema` property). If `None`, the class
representing the latest JSON schema draft supported by the `jsonschema` package

Returns
-------
JsonschemaValidator
The JSON schema validator

Raises
------
jsonschema.exceptions.SchemaError
If the JSON schema is invalid
"""
# Retrieve appropriate validator class for validating the given schema
validator_cls: type[JsonschemaValidator] = (
validator_for(schema, default_cls) if default_cls is not None else validator_for(schema)
)

# Ensure the schema is valid
validator_cls.check_schema(schema)

if check_format:
# Return a validator with format checking enabled
return validator_cls(schema, format_checker=validator_cls.FORMAT_CHECKER)

# Return a validator with format checking disabled
return validator_cls(schema)
Loading