Skip to content
This repository has been archived by the owner on Dec 5, 2023. It is now read-only.

Commit

Permalink
Configs can now define custom schema id and schema metadata prope… (#42)
Browse files Browse the repository at this point in the history
Configs can now define custom schema id and schema metadata properties
  • Loading branch information
stephen-bunn committed Oct 7, 2019
2 parents 368ab28 + 8ce7af7 commit b33b3af
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 15 deletions.
1 change: 1 addition & 0 deletions news/41.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adding the ability to define custom JSONSchema ``$id`` and ``$schema`` properties. Will raise user warnings if unsupported schema draft is specified.
42 changes: 39 additions & 3 deletions src/file_config/_file_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,15 @@ def _handle_load(cls, handler, file_object, validate=False, **kwargs):
return from_dict(cls, handler.load(cls, file_object, **kwargs), validate=validate)


def config(maybe_cls=None, these=None, title=None, description=None, **kwargs):
def config(
maybe_cls=None,
these=None,
title=None,
description=None,
schema_id=None,
schema_draft=None,
**kwargs,
):
""" File config class decorator.
Usage is to simply decorate a **class** to make it a
Expand All @@ -116,6 +124,10 @@ class MyConfig(object):
:param dict these: A dictionary of str to ``file_config.var`` to use as attribs
:param str title: The title of the config, defaults to None, optional
:param str description: A description of the config, defaults to None, optional
:param str schema_id: The JSONSchema ``$id`` to use when building the schema,
defaults to None, optional
:param str schema_raft: The JSONSchema ``$schema`` to use when building the schema,
defaults to None, optional
:return: Config wrapped class
:rtype: class
"""
Expand All @@ -128,7 +140,17 @@ def wrap(config_cls):
:rtype: class
"""

setattr(config_cls, CONFIG_KEY, dict(title=title, description=description))
setattr(
config_cls,
CONFIG_KEY,
dict(
title=title,
description=description,
schema_id=schema_id,
schema_draft=schema_draft,
),
)

# dynamically assign available handlers to the wrapped class
for handler_name in handlers.__all__:
handler = getattr(handlers, handler_name)
Expand Down Expand Up @@ -241,7 +263,15 @@ class MyConfig(object):
)


def make_config(name, var_dict, title=None, description=None, **kwargs):
def make_config(
name,
var_dict,
title=None,
description=None,
schema_id=None,
schema_draft=None,
**kwargs,
):
""" Creates a config instance from scratch.
Usage is virtually the same as :func:`attr.make_class`.
Expand All @@ -256,6 +286,10 @@ def make_config(name, var_dict, title=None, description=None, **kwargs):
:param dict var_dict: The dictionary of config variable definitions
:param str title: The title of the config, defaults to None, optional
:param str description: The description of the config, defaults to None, optional
:param str schema_id: The JSONSchema ``$id`` to use when building the schema,
defaults to None, optional
:param str schema_raft: The JSONSchema ``$schema`` to use when building the schema,
defaults to None, optional
:return: A new config class
:rtype: class
"""
Expand All @@ -265,6 +299,8 @@ def make_config(name, var_dict, title=None, description=None, **kwargs):
these=var_dict,
title=title,
description=description,
schema_id=schema_id,
schema_draft=schema_draft,
)


Expand Down
32 changes: 29 additions & 3 deletions src/file_config/schema_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
)
from .constants import CONFIG_KEY, REGEX_TYPE_NAME

DEFAULT_SCHEMA_DRAFT = "http://json-schema.org/draft-07/schema#"
SUPPORTED_SCHEMA_DRAFTS = (DEFAULT_SCHEMA_DRAFT,)


def Regex(pattern):
""" A custom typing type to store regular expressions for schema building.
Expand Down Expand Up @@ -446,9 +449,24 @@ def _build_config(config_cls, property_path=None):

# if the length of the property path is 0, assume that current object is root
if len(property_path) <= 0:
schema["$id"] = f"{config_cls.__qualname__}.json"
# NOTE: requires draft-07 for typing.Union type schema generation
schema["$schema"] = "http://json-schema.org/draft-07/schema#"
schema_id = cls_entry.get("schema_id")
if schema_id is None:
schema_id = f"{config_cls.__qualname__}.json"
schema["$id"] = schema_id

# NOTE: requires at leaast draft-07 for typing.Union type schema generation
schema_draft = cls_entry.get("schema_draft")
if schema_draft is None:
schema_draft = DEFAULT_SCHEMA_DRAFT
if schema_draft not in SUPPORTED_SCHEMA_DRAFTS:
warnings.warn(
"Specifying a custom JSONSchema draft is allowed but not advised. "
"We depend on the features provided in drafts "
f"{SUPPORTED_SCHEMA_DRAFTS!s} and functionality may become unstable if "
"using an unsupported draft."
)
schema["$schema"] = schema_draft

else:
schema["$id"] = f"#/{'/'.join(property_path)}"

Expand Down Expand Up @@ -504,6 +522,14 @@ def _build(value, property_path=None):
def build_schema(config_cls):
""" Builds the JSONSchema for a given config class.
.. important:: Although you can configure the generated JSONSchema's ``$id`` and
``$schema`` properties through the :func:`file_config._file_config.config`
decorator, this method will default those values for you. You should be wary of
using a ``$schema`` draft of anything less than
`draft-07 <https://json-schema.org/draft-07/json-schema-release-notes.html>`_ as
we rely on the specified functionality for handling both union and regex pattern
matching types.
:param class config_cls: The config class to build the JSONSchema for
:return: The resulting JSONSchema
:rtype: dict
Expand Down
1 change: 0 additions & 1 deletion tests/handlers/test_msgpack.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ def test_msgpack_reflective(instance):
# deal with message pack limitations in integer limits
assume(instance_arg >= -(2 ** 64) and instance_arg <= (2 ** 64) - 1)

print(instance)
content = instance.dumps_msgpack(prefer="msgpack")
assert isinstance(content, bytes)
loaded = instance.__class__.loads_msgpack(content)
Expand Down
70 changes: 62 additions & 8 deletions tests/test_schema_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import typing
import pytest
from hypothesis import given, settings
from hypothesis.strategies import characters
from hypothesis import given, settings, assume
from hypothesis.strategies import characters, sampled_from

import file_config
from .strategies import config, builtins, class_name, variable_name
Expand All @@ -31,14 +31,65 @@ def test_not_var():
file_config.schema_builder._build_var(None)


@given(class_name(), characters(), characters())
def test_config_metadata(config_name, title, description):
@given(class_name(), characters(), characters(), characters(), characters())
def test_config_metadata(config_name, title, description, schema_id, schema_draft):
config = file_config.make_config(
config_name, {}, title=title, description=description
config_name,
{},
title=title,
description=description,
schema_id=schema_id,
schema_draft=schema_draft,
)
schema = file_config.build_schema(config)
# NOTE: this will always cause a warning since we are always passing in unsupported
# draft names into the schema build
with pytest.warns(UserWarning):
schema = file_config.build_schema(config)

assert schema["title"] == title
assert schema["description"] == description
assert schema["$id"] == schema_id
assert schema["$schema"] == schema_draft


@given(class_name())
def test_config_default_schema_metadata(config_name):
default_config = file_config.make_config(config_name, {})
default_schema = file_config.build_schema(default_config)

assert isinstance(default_schema["$id"], str)
assert len(default_schema["$id"]) > 0
assert default_schema["$schema"] == file_config.schema_builder.DEFAULT_SCHEMA_DRAFT


@given(
class_name(),
characters(),
sampled_from(file_config.schema_builder.SUPPORTED_SCHEMA_DRAFTS),
)
def test_config_valid_schema_metadata(config_name, schema_id, schema_draft):
custom_config = file_config.make_config(
config_name, {}, schema_id=schema_id, schema_draft=schema_draft
)

custom_schema = file_config.build_schema(custom_config)
assert custom_schema["$id"] == schema_id
assert custom_schema["$schema"] == schema_draft


@given(class_name(), characters(), characters())
def test_config_invalid_schema_metadata(config_name, schema_id, schema_draft):
assume(schema_draft not in file_config.schema_builder.SUPPORTED_SCHEMA_DRAFTS)
custom_config = file_config.make_config(
config_name, {}, schema_id=schema_id, schema_draft=schema_draft
)

# should raise user warning when building a schema with unsupported draft versions
with pytest.warns(UserWarning):
custom_schema = file_config.build_schema(custom_config)

assert custom_schema["$id"] == schema_id
assert custom_schema["$schema"] == schema_draft


@given(class_name())
Expand Down Expand Up @@ -346,10 +397,13 @@ def test_var_modifier_exceptions(config_name):
with pytest.raises(ValueError):
file_config.schema_builder._build_attribute_modifiers(None, {})

config = file_config.make_config(config_name, {"test": file_config.var(str, min=True)})
config = file_config.make_config(
config_name, {"test": file_config.var(str, min=True)}
)
with pytest.raises(ValueError):
file_config.build_schema(config)


def test_generic_build():
config = file_config.make_config("A", {"test": file_config.var(str)})
# build schema for config instance
Expand All @@ -374,4 +428,4 @@ def test_generic_build():

# build schema for arbitrary value
schema = file_config.schema_builder._build("test")
assert schema["type"] == "string"
assert schema["type"] == "string"

0 comments on commit b33b3af

Please sign in to comment.