Skip to content
This repository was archived by the owner on Dec 28, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[submodule "JSON-Schema-Test-Suite"]
path = tests/JSON-Schema-Test-Suite
url = https://github.com/json-schema-org/JSON-Schema-Test-Suite
branch = master
branch = main
[submodule "json-schema-spec-2019-09"]
path = jschon/catalog/json-schema-spec-2019-09
url = https://github.com/json-schema-org/json-schema-spec
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Features:
* JSON ``null``, ``true``, ``false`` literals
* Relative JSON Pointer ``+``/``-`` array index adjustments
* Unknown keywords are collected as annotations
* Automatically create metaschemas as referenced by ``"$schema"``
* Automatically detect the core vocabulary in metaschemas,
but allow specifying a default to use when none is detectable

Experimental:

Expand All @@ -23,6 +26,11 @@ Breaking changes:
* ``Catalog.add_format_validators()`` superseded by ``@format_validator`` / ``Catalog.enable_formats()``
* Rename ``Catalog.session()`` context manager to ``Catalog.cache()``
* Rename ``session`` parameter to ``cacheid`` in many places
* Added ``Catalog.get_metaschema()``, analogous to ``Catalog.get_schema()``
* ``Catalog.create_metashema()`` and ``Catalog.create_vocabulary()`` return the created instance
* Rename ``core_vocabulary`` and ``core_vocabulary_uri`` parameters for
``Metaschema.__init__()`` and ``Catalog.create_metaschema()`` respectively to
``default_core_vocabulary`` and ``default_core_vocabulary_uri``
* Rename public functions in the ``jsonpatch`` module
* Rename ``*Applicator*`` keyword class mixins to ``*Subschema*``

Expand Down
14 changes: 0 additions & 14 deletions examples/custom_keyword.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,6 @@ def evaluate(self, instance: JSON, result: Result) -> None:
EnumRefKeyword,
)

# compile the enumRef metaschema, which enables any referencing schema
# to use the keyword implementations provided by its vocabularies
catalog.create_metaschema(
URI("https://example.com/enumRef/enumRef-metaschema"),
URI("https://json-schema.org/draft/2020-12/vocab/core"),
URI("https://json-schema.org/draft/2020-12/vocab/applicator"),
URI("https://json-schema.org/draft/2020-12/vocab/unevaluated"),
URI("https://json-schema.org/draft/2020-12/vocab/validation"),
URI("https://json-schema.org/draft/2020-12/vocab/format-annotation"),
URI("https://json-schema.org/draft/2020-12/vocab/meta-data"),
URI("https://json-schema.org/draft/2020-12/vocab/content"),
URI("https://example.com/enumRef"),
)

# create a schema for validating that a string is a member of a remote enumeration
schema = JSONSchema({
"$schema": "https://example.com/enumRef/enumRef-metaschema",
Expand Down
10 changes: 0 additions & 10 deletions jschon/catalog/_2019_09.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,3 @@ def initialize(catalog: Catalog):
ContentEncodingKeyword,
ContentSchemaKeyword,
)

catalog.create_metaschema(
URI("https://json-schema.org/draft/2019-09/schema"),
URI("https://json-schema.org/draft/2019-09/vocab/core"),
URI("https://json-schema.org/draft/2019-09/vocab/applicator"),
URI("https://json-schema.org/draft/2019-09/vocab/validation"),
URI("https://json-schema.org/draft/2019-09/vocab/format"),
URI("https://json-schema.org/draft/2019-09/vocab/meta-data"),
URI("https://json-schema.org/draft/2019-09/vocab/content"),
)
11 changes: 0 additions & 11 deletions jschon/catalog/_2020_12.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,3 @@ def initialize(catalog: Catalog):
ContentEncodingKeyword,
ContentSchemaKeyword,
)

catalog.create_metaschema(
URI("https://json-schema.org/draft/2020-12/schema"),
URI("https://json-schema.org/draft/2020-12/vocab/core"),
URI("https://json-schema.org/draft/2020-12/vocab/applicator"),
URI("https://json-schema.org/draft/2020-12/vocab/unevaluated"),
URI("https://json-schema.org/draft/2020-12/vocab/validation"),
URI("https://json-schema.org/draft/2020-12/vocab/format-annotation"),
URI("https://json-schema.org/draft/2020-12/vocab/meta-data"),
URI("https://json-schema.org/draft/2020-12/vocab/content"),
)
64 changes: 56 additions & 8 deletions jschon/catalog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ def __init__(self, name: str = 'catalog') -> None:
self._schema_cache: Dict[Hashable, Dict[URI, JSONSchema]] = {}
self._enabled_formats: Set[str] = set()

def __repr__(self) -> str:
"""Return `repr(self)`."""
return f'{self.__class__.__name__}({self.name!r})'

def add_uri_source(self, base_uri: URI, source: Source):
"""Register a source for URI-identified JSON resources.

Expand Down Expand Up @@ -137,16 +141,19 @@ def load_json(self, uri: URI) -> JSONCompatible:

raise CatalogError(f'A source is not available for "{uri}"')

def create_vocabulary(self, uri: URI, *kwclasses: KeywordClass) -> None:
def create_vocabulary(self, uri: URI, *kwclasses: KeywordClass) -> Vocabulary:
"""Create a :class:`~jschon.vocabulary.Vocabulary` object, which
may be used by a :class:`~jschon.vocabulary.Metaschema` to provide
keyword classes used in schema construction.

:param uri: the URI identifying the vocabulary
:param kwclasses: the :class:`~jschon.vocabulary.Keyword` classes
constituting the vocabulary

:returns: the newly created :class:`Vocabulary` instance
"""
self._vocabularies[uri] = Vocabulary(uri, *kwclasses)
return self._vocabularies[uri]

def get_vocabulary(self, uri: URI) -> Vocabulary:
"""Get a :class:`~jschon.vocabulary.Vocabulary` by its `uri`.
Expand All @@ -162,37 +169,78 @@ def get_vocabulary(self, uri: URI) -> Vocabulary:
def create_metaschema(
self,
uri: URI,
core_vocabulary_uri: URI,
default_core_vocabulary_uri: URI = None,
*default_vocabulary_uris: URI,
**kwargs: Any,
) -> None:
) -> Metaschema:
"""Create, cache and validate a :class:`~jschon.vocabulary.Metaschema`.

:param uri: the URI identifying the metaschema
:param core_vocabulary_uri: the URI identifying the metaschema's
core :class:`~jschon.vocabulary.Vocabulary`
:param default_core_vocabulary_uri: the URI identifying the metaschema's
core :class:`~jschon.vocabulary.Vocabulary`, used in the absence
of a ``"$vocabulary"`` keyword in the metaschema JSON file, or
if a known core vocabulary is not present under ``"$vocabulary"``
:param default_vocabulary_uris: default :class:`~jschon.vocabulary.Vocabulary`
URIs, used in the absence of a ``"$vocabulary"`` keyword in the
metaschema JSON file
:param kwargs: additional keyword arguments to pass through to the
:class:`~jschon.jsonschema.JSONSchema` constructor

:returns: the newly created :class:`Metaschema` instance

:raise CatalogError: if the metaschema is not valid
"""
metaschema_doc = self.load_json(uri)
core_vocabulary = self.get_vocabulary(core_vocabulary_uri)
default_core_vocabulary = (
self.get_vocabulary(default_core_vocabulary_uri)
if default_core_vocabulary_uri
else None
)
default_vocabularies = [
self.get_vocabulary(vocab_uri)
for vocab_uri in default_vocabulary_uris
]
metaschema = Metaschema(
self,
metaschema_doc,
core_vocabulary,
default_core_vocabulary,
*default_vocabularies,
**kwargs,
uri=uri,
)
if not metaschema.validate().valid:
raise CatalogError("The metaschema is invalid against itself")
raise CatalogError(
"The metaschema is invalid against its own metaschema "
f'"{metaschema_doc["$schema"]}"'
)
return metaschema

def get_metaschema(self, uri: URI) -> Metaschema:
"""Get a metaschema identified by `uri` from the ``'__meta__'`` cache, or
load it from configured sources if not already cached.

Note that metaschemas that do not declare a known core vocabulary
in ``$vocabulary`` must first be created using :meth:`create_metaschema`.

:param uri: the URI identifying the metaschema

:raise CatalogError: if the object referenced by `uri` is not
a :class:`~jschon.vocabulary.Metaschema`, or if it is not valid
:raise JSONSchemaError: if the metaschema is loaded from sources
but no known core vocabulary is present in ``$vocabulary``
"""
try:
metaschema = self._schema_cache['__meta__'][uri]
except KeyError:
metaschema = None

if not metaschema:
metaschema = self.create_metaschema(uri)

if not isinstance(metaschema, Metaschema):
raise CatalogError(f"The schema referenced by {uri} is not a metaschema")

return metaschema

def enable_formats(self, *format_attr: str) -> None:
"""Enable validation of the specified format attributes.
Expand Down
11 changes: 0 additions & 11 deletions jschon/catalog/_next.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,3 @@ def initialize(catalog: Catalog):
ContentEncodingKeyword,
ContentSchemaKeyword,
)

catalog.create_metaschema(
URI("https://json-schema.org/draft/next/schema"),
URI("https://json-schema.org/draft/next/vocab/core"),
URI("https://json-schema.org/draft/next/vocab/applicator"),
URI("https://json-schema.org/draft/next/vocab/unevaluated"),
URI("https://json-schema.org/draft/next/vocab/validation"),
URI("https://json-schema.org/draft/next/vocab/format-annotation"),
URI("https://json-schema.org/draft/next/vocab/meta-data"),
URI("https://json-schema.org/draft/next/vocab/content"),
)
10 changes: 2 additions & 8 deletions jschon/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,21 +218,15 @@ def parentschema(self) -> Optional[JSONSchema]:
return parent
parent = parent.parent

@property
@cached_property
def metaschema(self) -> Metaschema:
"""The schema's :class:`~jschon.vocabulary.Metaschema`."""
from jschon.vocabulary import Metaschema

if (uri := self.metaschema_uri) is None:
raise JSONSchemaError("The schema's metaschema URI has not been set")

if not isinstance(
metaschema := self.catalog.get_schema(uri, cacheid='__meta__'),
Metaschema,
):
raise JSONSchemaError(f"The schema referenced by {uri} is not a metachema")

return metaschema
return self.catalog.get_metaschema(uri)

@property
def metaschema_uri(self) -> Optional[URI]:
Expand Down
47 changes: 45 additions & 2 deletions jschon/vocabulary/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import re
import inspect
from typing import Any, Dict, Mapping, Optional, Sequence, TYPE_CHECKING, Tuple, Type

from jschon.json import JSON, JSONCompatible
from jschon.jsonschema import JSONSchema, Result
from jschon.exceptions import JSONSchemaError
from jschon.uri import URI

if TYPE_CHECKING:
Expand All @@ -30,17 +32,54 @@ class Metaschema(JSONSchema):
:class:`Metaschema` is itself a subclass of :class:`~jschon.jsonschema.JSONSchema`,
and may be used to validate any referencing schema.
"""
_CORE_VOCAB_RE = r'https://json-schema\.org/draft/[^/]*/vocab/core$'

def __init__(
self,
catalog: Catalog,
value: Mapping[str, JSONCompatible],
core_vocabulary: Vocabulary,
default_core_vocabulary: Vocabulary = None,
*default_vocabularies: Vocabulary,
**kwargs: Any,
):
self.core_vocabulary: Vocabulary = core_vocabulary
"""Initialize a :class:`Metaschema` instance from the given
schema-compatible `value`.

:param catalog: catalog instance or catalog name
:param value: a schema-compatible Python object
:param default_core_vocabulary: the the metaschema's
core :class:`~jschon.vocabulary.Vocabulary`, used in the absence
of a ``"$vocabulary"`` keyword in the metaschema JSON file, or
if a known core vocabulary is not present under ``"$vocabulary"``
:param default_vocabulary: default :class:`~jschon.vocabulary.Vocabulary`
instances, used in the absence of a ``"$vocabulary"`` keyword in the
metaschema JSON file
:param kwargs: additional keyword arguments to pass through to the
:class:`~jschon.jsonschema.JSONSchema` constructor

:raise JSONSchemaError: if no core vocabulary can be determined
:raise CatalogError: if the created metaschema is not valid
"""
self.default_vocabularies: Tuple[Vocabulary, ...] = default_vocabularies
self.core_vocabulary: Vocabulary = default_core_vocabulary

if vocabularies := value.get("$vocabulary"):
possible_cores = list(filter(
lambda v: re.match(self._CORE_VOCAB_RE, v),
vocabularies,
))
if len(possible_cores) == 1:
self.core_vocabulary = catalog.get_vocabulary(URI(possible_cores[0]))
else:
raise JSONSchemaError(
'Cannot determine unique known core vocabulary from '
f'candidates "{vocabularies.keys()}"'
)
if self.core_vocabulary is None:
raise JSONSchemaError(
f'No core vocabulary in "$vocabulary": {value}, and no default provided'
)

self.kwclasses: Dict[str, KeywordClass] = {}
super().__init__(value, catalog=catalog, cacheid='__meta__', **kwargs)

Expand Down Expand Up @@ -80,6 +119,10 @@ def __init__(self, uri: URI, *kwclasses: KeywordClass):
kwclass.key: kwclass for kwclass in kwclasses
}

def __repr__(self) -> str:
"""Return `repr(self)`."""
return f'{self.__class__.__name__}({self.uri!r})'


class Keyword:
key: str = ...
Expand Down
2 changes: 1 addition & 1 deletion tests/JSON-Schema-Test-Suite
4 changes: 4 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
metaschema_uri_2020_12 = URI("https://json-schema.org/draft/2020-12/schema")
metaschema_uri_next = URI("https://json-schema.org/draft/next/schema")

core_vocab_uri_2019_09 = URI("https://json-schema.org/draft/2019-09/vocab/core")
core_vocab_uri_2020_12 = URI("https://json-schema.org/draft/2020-12/vocab/core")
core_vocab_uri_next = URI("https://json-schema.org/draft/next/vocab/core")

example_schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "dynamicRef8_main.json",
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def pytest_addoption(parser):
)


@pytest.fixture(scope='module', autouse=True)
@pytest.fixture(autouse=True)
def catalog():
return create_catalog('2019-09', '2020-12', 'next')

Expand Down
8 changes: 8 additions & 0 deletions tests/data/meta_invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/meta_invalid",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/core": true
},
"type": {"lol": "cats"}
}
4 changes: 4 additions & 0 deletions tests/data/meta_no_vocabs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/meta_no_vocabs"
}
7 changes: 7 additions & 0 deletions tests/data/meta_with_core.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/meta_with_core",
"$vocabulary": {
"https://json-schema.org/draft/2020-12/vocab/core": true
}
}
Loading