From dd1a82545f48ac0a3956c346c5ae4305e3b93b85 Mon Sep 17 00:00:00 2001 From: Chris Wacek Date: Tue, 5 Mar 2024 11:31:07 -0500 Subject: [PATCH] feature: Add support for the const keyword. Fixes #229 --- python_jsonschema_objects/classbuilder.py | 30 +++++++++++-- python_jsonschema_objects/literals.py | 8 ++++ python_jsonschema_objects/validators.py | 6 +++ test/test_229.py | 51 +++++++++++++++++++++++ test/test_default_values.py | 6 ++- 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 test/test_229.py diff --git a/python_jsonschema_objects/classbuilder.py b/python_jsonschema_objects/classbuilder.py index e9aaabe..783b064 100644 --- a/python_jsonschema_objects/classbuilder.py +++ b/python_jsonschema_objects/classbuilder.py @@ -4,6 +4,7 @@ import logging import sys +import jsonschema.exceptions import referencing._core import six @@ -184,11 +185,23 @@ def __init__(self, **props): # but only for the ones that have defaults set. for name in self.__has_default__: if name not in props: - default_value = copy.deepcopy(self.__propinfo__[name]["default"]) + # "defaults" could come from either the 'default' keyword or the 'const' keyword + try: + default_value = self.__propinfo__[name]["default"] + except KeyError: + try: + default_value = self.__propinfo__[name]["const"] + except KeyError: + raise jsonschema.exceptions.SchemaError( + "Schema parsing error. Expected {0} to have default or const value".format( + name + ) + ) + logger.debug( util.lazy_format("Initializing '{0}' to '{1}'", name, default_value) ) - setattr(self, name, default_value) + setattr(self, name, copy.deepcopy(default_value)) for prop in props: try: @@ -626,7 +639,7 @@ def _build_literal(self, nm, clsdata): "__propinfo__": { "__literal__": clsdata, "__title__": clsdata.get("title"), - "__default__": clsdata.get("default"), + "__default__": clsdata.get("default") or clsdata.get("const"), } }, ) @@ -670,6 +683,17 @@ def _build_object(self, nm, clsdata, parents, **kw): ) defaults.add(prop) + if "const" in detail: + logger.debug( + util.lazy_format( + "Setting const for {0}.{1} to: {2}", + nm, + prop, + detail["const"], + ) + ) + defaults.add(prop) + if detail.get("type", None) == "object": uri = "{0}/{1}_{2}".format(nm, prop, "") self.resolved[uri] = self.construct(uri, detail, (ProtocolBase,), **kw) diff --git a/python_jsonschema_objects/literals.py b/python_jsonschema_objects/literals.py index d56c946..37367e1 100644 --- a/python_jsonschema_objects/literals.py +++ b/python_jsonschema_objects/literals.py @@ -44,6 +44,10 @@ def __init__(self, value, typ=None): self.validate() + constval = self.const() + if constval is not None: + self._value = constval + def as_dict(self): return self.for_json() @@ -54,6 +58,10 @@ def for_json(self): def default(cls): return cls.__propinfo__.get("__default__") + @classmethod + def const(cls): + return cls.__propinfo__.get("__literal__", {}).get("const", None) + @classmethod def propinfo(cls, propname): if propname not in cls.__propinfo__: diff --git a/python_jsonschema_objects/validators.py b/python_jsonschema_objects/validators.py index 92a792d..b2921a2 100644 --- a/python_jsonschema_objects/validators.py +++ b/python_jsonschema_objects/validators.py @@ -58,6 +58,12 @@ def enum(param, value, _): raise ValidationError("{0} is not one of {1}".format(value, param)) +@registry.register() +def const(param, value, _): + if value != param: + raise ValidationError("{0} is not constant {1}".format(value, param)) + + @registry.register() def minimum(param, value, type_data): exclusive = type_data.get("exclusiveMinimum") diff --git a/test/test_229.py b/test/test_229.py new file mode 100644 index 0000000..cb8d36a --- /dev/null +++ b/test/test_229.py @@ -0,0 +1,51 @@ +import pytest + +import python_jsonschema_objects as pjo + + +def test_const_properties(): + schema = { + "title": "Example", + "type": "object", + "properties": { + "url": { + "type": "string", + "default": "https://example.com/your-username/my-project", + }, + "type": {"type": "string", "const": "git"}, + }, + } + + ob = pjo.ObjectBuilder(schema) + ns1 = ob.build_classes() + ex = ns1.Example() + ex.url = "can be anything" + + # we expect the value to be set already for const values + assert ex.type == "git" + with pytest.raises(pjo.ValidationError): + # Trying to set the value to something else should throw validation errors + ex.type = "mercurial" + + # setting the value to the const value is a no-op, but permitted + ex.type = "git" + + +def test_const_bare_type(): + schema = { + "title": "Example", + "type": "string", + "const": "I stand alone", + } + + ob = pjo.ObjectBuilder(schema) + ns1 = ob.build_classes() + ex = ns1.Example("I stand alone") + # we expect the value to be set already for const values + assert ex == "I stand alone" + with pytest.raises(pjo.ValidationError): + # Trying to set the value to something else should throw validation errors + ex = ns1.Example("mercurial") + + # setting the value to the const value is a no-op, but permitted + ex = ns1.Example("I stand alone") diff --git a/test/test_default_values.py b/test/test_default_values.py index dc76ec5..3c0743f 100644 --- a/test/test_default_values.py +++ b/test/test_default_values.py @@ -53,15 +53,17 @@ def test_nullable_types_are_still_nullable(ns): thing1.p1 = None thing1.validate() - assert thing1.as_dict() == {"p1": 0, "p2": None} + assert thing1.as_dict() == {"p1": None, "p2": None} def test_null_types_without_defaults_do_not_serialize(ns): thing1 = ns.DefaultTest() + assert thing1.as_dict() == {"p1": 0, "p2": None} + thing1.p3 = 10 thing1.validate() thing1.p1 = None thing1.validate() - assert thing1.as_dict() == {"p1": 0, "p2": None, "p3": 10} + assert thing1.as_dict() == {"p1": None, "p2": None, "p3": 10}