Repository: cwacek/python-jsonschema-objects

Issue:
Invalid resolution of nested `oneOf` for schemas of differing types
**Describe the bug**

When nested `oneOf` are used with types that do not match, the resolution fails.
This is somewhat related to #214, but contrary to the `oneOf` using two distinct `object` references in:
https://github.com/cwacek/python-jsonschema-objects/blob/master/test/test_regression_214.py#L28-L33

... the nested `oneOf` also uses different types (as the parent one does in https://github.com/cwacek/python-jsonschema-objects/blob/master/test/test_regression_214.py#L17-L21).

**Example Schema and code**

I reused the existing test with slight modifications to demonstrate the issue.
https://github.com/cwacek/python-jsonschema-objects/compare/master...fmigneault:python-jsonschema-objects:nested-oneOf-regression-example

**Expected behavior**

As demonstrated with `jsonschema.validate` (https://github.com/cwacek/python-jsonschema-objects/compare/master...fmigneault:python-jsonschema-objects:nested-oneOf-regression-example#diff-8af35489f471b9a4a568c247e1bc375c74c9d577e540975ef8fab8d5feae8575R81), the schema is valid. 
It is expected that each combination of 
```python
    obj1 = ns.MainObject(**{"location": 2})  # FAIL!
    obj2 = ns.MainObject(**{"location": {"type": "Location2"}})
    obj3 = ns.MainObject(**{"location": "unique:12"})
```
Would resolve to the appropriate type/schema without error.

A quick workaround that lets the resolution succeed is to move the `Location1` (number) reference under the parent `oneOf`. In this case, it resolves correctly (just like `UNIQUE_STRING` did). However, because of specific schema definitions and references used elsewhere in by actual schema, it cannot be moved like so, and must remain in the nested `oneOf`.

Comments:

Patch:
diff --git a/python_jsonschema_objects/descriptors.py b/python_jsonschema_objects/descriptors.py
index 3860307..421df26 100644
--- a/python_jsonschema_objects/descriptors.py
+++ b/python_jsonschema_objects/descriptors.py
@@ -80,7 +80,13 @@ class AttributeDescriptor(object):
                         break
                 elif isinstance(typ, TypeProxy):
                     try:
-                        val = typ(**util.coerce_for_expansion(val))
+                        # handle keyword expansion according to expected types
+                        # using keywords like oneOf, value can be an object, array or literal
+                        val = util.coerce_for_expansion(val)
+                        if isinstance(val, dict):
+                            val = typ(**val)
+                        else:
+                            val = typ(val)
                         val.validate()
                     except Exception as e:
                         errors.append("Failed to coerce to '{0}': {1}".format(typ, e))
@@ -119,7 +125,11 @@ class AttributeDescriptor(object):
             val.validate()
 
         elif isinstance(info["type"], TypeProxy):
-            val = info["type"](val)
+            val = util.coerce_for_expansion(val)
+            if isinstance(val, dict):
+                val = info["type"](**val)
+            else:
+                val = info["type"](val)
 
         elif isinstance(info["type"], TypeRef):
             if not isinstance(val, info["type"].ref_class):
diff --git a/python_jsonschema_objects/pattern_properties.py b/python_jsonschema_objects/pattern_properties.py
index 3ca1d07..e4ccfea 100644
--- a/python_jsonschema_objects/pattern_properties.py
+++ b/python_jsonschema_objects/pattern_properties.py
@@ -71,6 +71,14 @@ class ExtensibleValidator(object):
         ):
             return typ(val)
 
+        if isinstance(typ, cb.TypeProxy):
+            val = util.coerce_for_expansion(val)
+            if isinstance(val, dict):
+                val = typ(**val)
+            else:
+                val = typ(val)
+            return val
+
         raise validators.ValidationError(
             "additionalProperty type {0} was neither a literal "
             "nor a schema wrapper: {1}".format(typ, val)
@@ -95,7 +103,7 @@ class ExtensibleValidator(object):
             valtype = valtype[0]
             return MakeLiteral(name, valtype, val)
 
-        elif isinstance(self._additional_type, type):
+        elif isinstance(self._additional_type, (type, cb.TypeProxy)):
             return self._make_type(self._additional_type, val)
 
         raise validators.ValidationError(
diff --git a/tox.ini b/tox.ini
index b42775b..8195127 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,11 +1,10 @@
 
 [tox]
-envlist = py{35,36,37,38}-jsonschema{23,24,25,26,30}-markdown{2,3}
+envlist = py{36,37,38}-jsonschema{23,24,25,26,30}-markdown{2,3}
 skip_missing_interpreters = true
 
 [gh-actions]
 python =
-    3.5: py35
     3.6: py36
     3.7: py37
     3.8: py38
@@ -13,8 +12,8 @@ python =
 
 [testenv]
 ;install_command = pip install {opts} {packages}
-commands = coverage run {envbindir}/py.test --doctest-glob='python_jsonschema_objects/*.md'  {posargs} 
-           coverage xml --omit=*test* --include=*python_jsonschema_objects*
+commands = python -m coverage run {envbindir}/py.test --doctest-glob='python_jsonschema_objects/*.md'  {posargs}
+           python -m coverage xml --omit="*test*"
 deps =
   coverage
   pytest


Test patch:
diff --git a/test/test_nested_arrays.py b/test/test_nested_arrays.py
index a4aacb6..89f2f32 100644
--- a/test/test_nested_arrays.py
+++ b/test/test_nested_arrays.py
@@ -9,12 +9,14 @@ import logging
 @pytest.fixture
 def nested_arrays():
     return {
+        "$schema": "http://json-schema.org/draft-04/schema#",
         "title": "example",
         "properties": {
             "foo": {
                 "type": "array",
                 "items": {
                     "type": "array",
+                    # FIXME: not supported anymore in https://json-schema.org/draft/2020-12
                     "items": [{"type": "number"}, {"type": "number"}],
                 },
             }
diff --git a/test/test_regression_232.py b/test/test_regression_232.py
new file mode 100644
index 0000000..ca544d3
--- /dev/null
+++ b/test/test_regression_232.py
@@ -0,0 +1,129 @@
+import jsonschema
+import pytest
+import python_jsonschema_objects as pjo
+
+schema = {
+    "$schema": "http://json-schema.org/draft-07/schema",
+    "title": "myschema",
+    "type": "object",
+    "definitions": {
+        "RefObject": {
+            "title": "Ref Object",
+            "properties": {"location": {"$ref": "#/definitions/Location"}},
+        },
+        "MapObject": {
+            "title": "Map Object",
+            "additionalProperties": {"$ref": "#/definitions/Location"},
+        },
+        "MainObject": {
+            "title": "Main Object",
+            "additionalProperties": False,
+            "type": "object",
+            "properties": {
+                "location": {
+                    "title": "location",
+                    "oneOf": [
+                        {"$ref": "#/definitions/UNIQUE_STRING"},
+                        {"$ref": "#/definitions/Location"},
+                    ],
+                }
+            },
+        },
+        "Location": {
+            "title": "Location",
+            "description": "A Location represents a span on a specific sequence.",
+            "oneOf": [
+                {"$ref": "#/definitions/LocationIdentifier"},
+                {"$ref": "#/definitions/LocationTyped"},
+            ],
+        },
+        "LocationIdentifier": {
+            "type": "integer",
+            "minimum": 1,
+        },
+        "LocationTyped": {
+            "additionalProperties": False,
+            "type": "object",
+            "properties": {
+                "type": {
+                    "type": "string",
+                    "enum": ["Location"],
+                    "default": "Location",
+                }
+            },
+        },
+        "UNIQUE_STRING": {
+            "additionalProperties": False,
+            "type": "string",
+            "pattern": "^\\w[^:]*:.+$",
+        },
+    },
+}
+
+
+@pytest.fixture
+def schema_json():
+    return schema
+
+
+def test_nested_oneof_with_different_types(schema_json):
+    builder = pjo.ObjectBuilder(schema_json)
+    ns = builder.build_classes()
+
+    resolver = jsonschema.RefResolver.from_schema(schema_json)
+    main_obj = schema_json["definitions"]["MainObject"]
+
+    test1 = {"location": 12345}
+    test2 = {"location": {"type": "Location"}}
+    test3 = {"location": "unique:12"}
+    jsonschema.validate(test1, main_obj, resolver=resolver)
+    jsonschema.validate(test2, main_obj, resolver=resolver)
+    jsonschema.validate(test3, main_obj, resolver=resolver)
+
+    obj1 = ns.MainObject(**test1)
+    obj2 = ns.MainObject(**test2)
+    obj3 = ns.MainObject(**test3)
+
+    assert obj1.location == 12345
+    assert obj2.location.type == "Location"
+    assert obj3.location == "unique:12"
+
+
+def test_nested_oneof_with_different_types_by_reference(schema_json):
+    builder = pjo.ObjectBuilder(schema_json)
+    ns = builder.build_classes()
+
+    resolver = jsonschema.RefResolver.from_schema(schema_json)
+    ref_obj = schema_json["definitions"]["RefObject"]
+
+    test1 = {"location": 12345}
+    test2 = {"location": {"type": "Location"}}
+    jsonschema.validate(test1, ref_obj, resolver=resolver)
+    jsonschema.validate(test2, ref_obj, resolver=resolver)
+
+    obj1 = ns.RefObject(**test1)
+    obj2 = ns.RefObject(**test2)
+
+    assert obj1.location == 12345
+    assert obj2.location.type == "Location"
+
+
+def test_nested_oneof_with_different_types_in_additional_properties(schema_json):
+    builder = pjo.ObjectBuilder(schema_json)
+    ns = builder.build_classes()
+
+    resolver = jsonschema.RefResolver.from_schema(schema_json)
+    map_obj = schema_json["definitions"]["MapObject"]
+
+    x_prop_name = "location-id"
+
+    test1 = {x_prop_name: 12345}
+    test2 = {x_prop_name: {"type": "Location"}}
+    jsonschema.validate(test1, map_obj, resolver=resolver)
+    jsonschema.validate(test2, map_obj, resolver=resolver)
+
+    obj1 = ns.MapObject(**test1)
+    obj2 = ns.MapObject(**test2)
+
+    assert obj1[x_prop_name] == 12345
+    assert obj2[x_prop_name].type == "Location"