File size: 17,482 Bytes
f8dbde1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
"""Schema -> fill plan contract (SPEC.md §3-§4)."""

from __future__ import annotations

import copy
import json

import pytest
from deg_cases import ARRAYS, INVOICE, NESTED, PRODUCT_LISTING, SUPPORT_TICKET

from deg.schema import ITEM, SchemaError, compile_schema, pointer


def modes(plan):
    return [(f.pointer, f.mode) for f in plan.fields]


def obj(**props):
    return {"type": "object", "properties": props, "required": list(props)}


def errors_of(schema):
    with pytest.raises(SchemaError) as info:
        compile_schema(schema)
    return info.value.errors


# ----------------------------------------------------------------------------- examples


def test_support_ticket_plan():
    plan = compile_schema(SUPPORT_TICKET)
    assert modes(plan) == [
        ("/department", "decide.choice"),
        ("/urgent", "decide.noul"),
        ("/anger", "decide.score"),
        ("/customer_email", "extract.span"),
        ("/order_id", "extract.span"),
        ("/reply", "generate"),
    ]
    dept, _, anger, email, order, reply = plan.fields
    assert dept.options == ("billing", "technical", "sales")
    assert dept.description == "Which team should handle this?"
    assert anger.levels == (0, 1, 2, 3, 4)
    assert (email.format, email.nullable, email.on_null) == ("email", True, "null")
    assert (order.on_null, order.constraints) == ("omit", {"pattern": "^[A-Z]{2}-\\d+$"})
    assert reply.constraints == {"maxLength": 400} and reply.on_null == "forbid"
    assert [f.pointer for f in plan.generate_fields] == ["/reply"]
    assert len(plan.parallel_fields) == 5


def test_invoice_plan_refs_oneof_and_arrays():
    plan = compile_schema(INVOICE)
    assert modes(plan) == [
        ("/invoice_number", "extract.span"),
        ("/issued", "extract.date"),
        ("/due", "extract.date"),
        ("/vendor/name", "extract.span"),
        ("/vendor/website", "extract.span"),
        ("/currency", "decide.choice"),
        ("/line_items/*/description", "extract.span"),
        ("/line_items/*/quantity", "extract.number"),
        ("/line_items/*/amount", "extract.number"),
        ("/total", "extract.number"),
    ]
    currency = plan.field("/currency")
    assert currency.options == ("USD", "EUR", "GBP")
    assert currency.option_descriptions == ("US dollars", "Euros", None)
    amount = plan.field("/line_items/*/amount")
    assert amount.path == ("line_items", ITEM, "amount")
    assert amount.description == "Amount in invoice currency"  # from the $ref target
    assert amount.constraints == {"minimum": 0}
    assert plan.field("/line_items/*/quantity").on_null == "omit"
    assert plan.field("/due").on_null == "null"
    assert plan.field("/vendor/website").format == "uri"
    (items,) = plan.arrays
    assert (items.pointer, items.min_items, items.max_items) == ("/line_items", 1, 50)
    assert plan.generate_fields == ()


def test_product_listing_generates_only_marked_fields():
    plan = compile_schema(PRODUCT_LISTING)
    assert modes(plan) == [
        ("/title", "extract.span"),
        ("/category", "decide.choice"),
        ("/condition", "decide.choice"),
        ("/price", "extract.number"),  # one-sided bound: a quantity, extracted
        ("/rating", "extract.number"),  # a number: always extracted; bounds only validate
        ("/in_stock", "decide.noul"),
        ("/description", "generate"),
        ("/tags/*", "generate"),  # x-generate on the array applies to its string items
    ]
    rating = plan.field("/rating")
    assert rating.levels is None and rating.constraints == {"minimum": 0, "maximum": 5}
    assert [f.pointer for f in plan.generate_fields] == ["/description", "/tags/*"]
    assert plan.field("/tags/*").constraints == {"maxLength": 24}
    assert plan.arrays[0].max_items == 5


def test_nested_objects():
    plan = compile_schema(NESTED)
    assert modes(plan) == [
        ("/patient/name", "extract.span"),
        ("/patient/birth", "extract.date"),
        ("/patient/contact/email", "extract.span"),
        ("/patient/contact/id", "extract.span"),
        ("/visit/at", "extract.date"),
        ("/visit/follow_up", "decide.choice"),  # nullable boolean -> 3-way choice
        ("/visit/summary", "generate"),
    ]
    assert plan.field("/visit/follow_up").options == (True, False, None)
    assert plan.field("/visit/at").format == "date-time"
    assert plan.field("/patient/contact/id").format == "uuid"


def test_arrays_per_item():
    plan = compile_schema(ARRAYS)
    assert modes(plan) == [
        ("/labels/*", "decide.choice"),
        ("/people/*/name", "extract.span"),
        ("/people/*/aliases/*", "extract.span"),
        ("/matrix/*/*", "extract.number"),
    ]
    assert [a.pointer for a in plan.arrays] == [
        "/labels", "/people", "/people/*/aliases", "/matrix", "/matrix/*",
    ]
    assert plan.arrays[0].unique_items is True


def test_type_to_mode_table():
    plan = compile_schema(obj(
        e={"enum": [1, 2, 3]},
        mixed={"enum": ["a", 1, None]},
        b={"type": "boolean"},
        i_small={"type": "integer", "minimum": 1, "maximum": 10},
        i_step={"type": "integer", "minimum": 0, "maximum": 100, "multipleOf": 25},
        i_excl={"type": "integer", "exclusiveMinimum": 0, "exclusiveMaximum": 4},
        i_wide={"type": "integer", "minimum": 0, "maximum": 150},
        i_open={"type": "integer"},
        n_unit={"type": "number", "minimum": 0, "maximum": 1},
        n_excl={"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1},
        i_12={"type": "integer", "minimum": 0, "maximum": 11},
        n_open={"type": "number"},
        s={"type": "string"},
        d={"type": "string", "format": "date"},
        dt={"type": "string", "format": "date-time"},
        u={"type": "string", "format": "uuid"},
        g={"type": "string", "x-generate": True},
        g_false={"type": "string", "x-generate": False},
    ))
    got = {f.pointer: (f.mode, f.levels, f.options) for f in plan.fields}
    assert got["/e"] == ("decide.choice", None, (1, 2, 3))
    assert got["/mixed"] == ("decide.choice", None, ("a", 1, None))
    assert got["/b"][0] == "decide.noul"
    assert got["/i_small"] == ("decide.score", tuple(range(1, 11)), None)
    assert got["/i_step"] == ("decide.score", (0, 25, 50, 75, 100), None)
    assert got["/i_excl"] == ("decide.score", (1, 2, 3), None)
    assert got["/i_wide"][0] == "extract.number"
    assert got["/i_open"][0] == "extract.number"
    assert got["/n_unit"][0] == "extract.number"
    assert got["/n_excl"][0] == "extract.number"
    assert got["/i_12"][0] == "extract.number"  # 12 admissible values: extracted
    assert got["/n_open"][0] == "extract.number"
    assert got["/s"][0] == "extract.span"
    assert got["/d"][0] == got["/dt"][0] == "extract.date"
    assert got["/u"][0] == "extract.span"
    assert got["/g"][0] == "generate"
    assert got["/g_false"][0] == "extract.span"
    assert plan.field("/mixed").nullable is True


def test_nullable_forms():
    plan = compile_schema(obj(
        a={"type": ["string", "null"]},
        b={"anyOf": [{"type": "string"}, {"type": "null"}], "description": "B"},
        c={"oneOf": [{"type": "null"}, {"type": "integer", "minimum": 0, "maximum": 3}]},
        d={"anyOf": [{"enum": ["x", "y"]}, {"type": "null"}]},
        e={"type": ["string", "null"], "enum": ["x", "y", None]},
    ))
    a, b, c, d, e = plan.fields
    assert a.nullable and b.nullable and b.description == "B"
    assert (c.mode, c.levels, c.null_outcome) == ("decide.score", (0, 1, 2, 3), True)
    assert d.options == ("x", "y", None) and d.nullable
    assert e.options == ("x", "y", None)


def test_optional_fields_and_on_null():
    plan = compile_schema({
        "type": "object",
        "properties": {"req": {"type": "string"}, "opt": {"type": "string"},
                       "decided": {"type": "boolean"}},
        "required": ["req"],
    })
    assert [f.on_null for f in plan.fields] == ["forbid", "omit", "omit"]
    assert [f.required for f in plan.fields] == [True, False, False]


def test_pointer_escaping():
    assert pointer(("a/b", "c~d", ITEM, "*")) == "/a~1b/c~0d/*/~2"
    plan = compile_schema(obj(**{"a/b": {"type": "string"}}))
    assert plan.fields[0].pointer == "/a~1b"


def test_local_ref_with_annotation_override():
    schema = {
        "type": "object",
        "definitions": {"name": {"type": "string", "description": "orig"}},
        "properties": {"n": {"$ref": "#/definitions/name", "description": "override"}},
    }
    (field,) = compile_schema(schema).fields
    assert (field.mode, field.description) == ("extract.span", "override")


# ----------------------------------------------------------------------------- errors


@pytest.mark.parametrize(
    "bad",
    [
        {"enum": ["a", "b"], "x-generate": True},
        {"type": "string", "enum": ["a", "b"], "x-generate": True},
        {"type": "boolean", "x-generate": True},
        {"type": "number", "x-generate": True},
        {"type": "integer", "minimum": 0, "maximum": 3, "x-generate": True},
        {"type": "object", "properties": {}, "x-generate": True},
        {"type": "array", "items": {"type": "integer"}, "x-generate": True},
        {"type": "array", "items": {"enum": ["a", "b"]}, "x-generate": True},
    ],
)
def test_x_generate_only_on_string(bad):
    (error,) = errors_of(obj(f=bad))
    assert "x-generate is allowed only on string or array of string" in error["msg"]


def test_x_generate_with_format_and_bad_value():
    (error,) = errors_of(obj(f={"type": "string", "format": "email", "x-generate": True}))
    assert "format" in error["msg"]
    (error,) = errors_of(obj(f={"type": "string", "x-generate": "yes"}))
    assert error["msg"] == "x-generate must be a boolean"


@pytest.mark.parametrize(
    ("bad", "keyword"),
    [
        ({"allOf": [{"type": "string"}]}, "allOf"),
        ({"type": "string", "not": {"maxLength": 2}}, "not"),
        ({"type": "string", "if": {}, "then": {}}, "if"),
        ({"const": "x"}, "const"),
        ({"type": "object", "patternProperties": {"^a": {"type": "string"}}},
         "patternProperties"),
        ({"type": "array", "prefixItems": [{"type": "string"}]}, "prefixItems"),
        ({"type": "array", "items": {"type": "string"}, "contains": {"type": "string"}},
         "contains"),
        ({"type": "object", "properties": {}, "minProperties": 1}, "minProperties"),
        ({"$dynamicRef": "#x"}, "$dynamicRef"),
    ],
)
def test_unsupported_keywords(bad, keyword):
    errors = errors_of(obj(f=bad))
    assert any(f"unsupported keyword {keyword!r}" in e["msg"] for e in errors), errors


@pytest.mark.parametrize(
    ("bad", "fragment"),
    [
        ({"anyOf": [{"type": "string"}, {"type": "integer"}]}, "anyOf is supported only"),
        ({"oneOf": [{"type": "string"}, {"type": "object"}]}, "oneOf is supported only"),
        ({"type": ["string", "integer"]}, "union types are not supported"),
        ({"$ref": "https://example.com/s.json"}, "only local $ref"),
        ({"$ref": "#/$defs/missing"}, "unresolvable $ref"),
        ({"type": "object", "additionalProperties": {"type": "string"}}, "additionalProperties"),
        ({"type": ["object", "null"], "properties": {}}, "nullable objects/arrays"),
        ({"type": "array"}, "arrays need an items schema"),
        ({"type": "string", "format": "hostname"}, "unsupported format"),
        ({"type": "string", "x-foo": 1}, "unknown extension"),
        ({"type": "string", "frobnicate": 1}, "unknown keyword"),
        ({"type": "integer", "maxLength": 3}, "does not apply to type 'integer'"),
        ({"enum": ["only"]}, "a single value has nothing to decide"),
        ({"enum": ["a", "a"]}, "duplicate enum value"),
        ({"type": "string", "enum": ["a", 1]}, "does not match type"),
        ({"type": "integer", "minimum": 3, "maximum": 3}, "single value"),
        ({"type": "number", "minimum": 2, "maximum": 1}, "admit no value"),
            ({"type": "number", "minimum": 1, "exclusiveMaximum": 1}, "admit no value"),
        ({"type": "string", "pattern": "("}, "invalid"),
        ({}, "type is required"),
    ],
)
def test_validation_errors(bad, fragment):
    errors = errors_of(obj(f=bad))
    assert any(fragment in e["msg"] for e in errors), errors
    assert all(e["loc"][:3] == ["schema", "properties", "f"] for e in errors), errors


def test_draft07_tuple_items_rejected():
    schema = {"$schema": "http://json-schema.org/draft-07/schema#", **obj(
        f={"type": "array", "items": [{"type": "string"}]})}
    (error,) = errors_of(schema)
    assert "tuple arrays" in error["msg"]


def test_recursive_ref_rejected():
    schema = {
        "type": "object",
        "$defs": {"node": {"type": "object", "properties": {"child": {"$ref": "#/$defs/node"}}}},
        "properties": {"root": {"$ref": "#/$defs/node"}},
    }
    errors = errors_of(schema)
    assert any("recursive $ref" in e["msg"] for e in errors)


def test_root_must_be_object_and_errors_are_collected():
    assert "type: object" in errors_of({"type": "string"})[0]["msg"]
    errors = errors_of(obj(a={"type": "boolean", "x-generate": True},
                           b={"allOf": [{"type": "string"}]},
                           c={"type": "string", "format": "ipv4"}))
    assert sorted(e["loc"][2] for e in errors) == ["a", "b", "c"]
    with pytest.raises(SchemaError):
        compile_schema("not a schema")
    with pytest.raises(SchemaError):
        compile_schema({"type": "object", "properties": {"a": {"type": 5}}})  # invalid JSON Schema


def test_required_must_name_properties():
    errors = errors_of({"type": "object", "properties": {}, "required": ["ghost"]})
    assert "not in properties" in errors[0]["msg"]


# ----------------------------------------------------------------------------- stability


def test_plan_is_deterministic_and_does_not_mutate_schema():
    for schema in (SUPPORT_TICKET, INVOICE, PRODUCT_LISTING, NESTED, ARRAYS):
        before = copy.deepcopy(schema)
        a, b = compile_schema(schema), compile_schema(copy.deepcopy(schema))
        assert a.to_dict() == b.to_dict()
        assert json.dumps(a.to_dict(), sort_keys=True) == json.dumps(b.to_dict(), sort_keys=True)
        assert schema == before


def _reordered(schema, order):
    out = copy.deepcopy(schema)
    out["properties"] = {k: schema["properties"][k] for k in order}
    return out


def test_parallel_fields_are_order_independent():
    shuffled = _reordered(SUPPORT_TICKET, ["reply", "order_id", "anger", "urgent",
                                           "customer_email", "department"])
    a, b = compile_schema(SUPPORT_TICKET), compile_schema(shuffled)
    fa = {f.pointer: f.fingerprint for f in a.fields}
    fb = {f.pointer: f.fingerprint for f in b.fields}
    assert fa == fb
    assert a.fingerprint == b.fingerprint  # one generate field: order of the rest is moot


def test_generate_order_is_meaningful():
    schema = obj(
        kind={"enum": ["a", "b"]},
        first={"type": "string", "x-generate": True},
        second={"type": "string", "x-generate": True},
    )
    swapped = _reordered(schema, ["second", "kind", "first"])
    a, b = compile_schema(schema), compile_schema(swapped)
    assert [f.pointer for f in a.generate_fields] == ["/first", "/second"]
    assert [f.pointer for f in b.generate_fields] == ["/second", "/first"]
    assert a.fingerprint != b.fingerprint
    moved_parallel = _reordered(schema, ["first", "kind", "second"])
    assert compile_schema(moved_parallel).fingerprint == a.fingerprint


def test_field_fingerprint_independent_of_composition():
    """A leaf's spec is the same alone, next to other fields, or merged into a bigger schema."""
    alone = compile_schema(obj(department=SUPPORT_TICKET["properties"]["department"]))
    merged = compile_schema(obj(
        department=SUPPORT_TICKET["properties"]["department"],
        **{k: v for k, v in PRODUCT_LISTING["properties"].items()},
    ))
    assert alone.fields[0].fingerprint == merged.field("/department").fingerprint
    product = compile_schema(PRODUCT_LISTING)
    for f in product.parallel_fields:
        g = merged.field(f.pointer)
        # required flags match because obj() requires everything, as PRODUCT_LISTING does
        assert g.fingerprint == f.fingerprint


def test_x_levels_and_x_max_tokens_extensions():
    ok = compile_schema({"type": "object", "properties": {
        "s": {"type": "integer", "minimum": 1, "maximum": 3, "x-levels": {"1": "low", "3": "high"}},
        "g": {"type": "string", "x-generate": True, "x-max-tokens": 12},
        "tags": {"type": "array", "x-generate": True, "x-max-tokens": 5,
                 "items": {"type": "string"}}}})
    by = {f.pointer: f for f in ok.fields}
    assert by["/s"].level_labels == ("low", None, "high")
    assert by["/g"].max_new_tokens == 12 and by["/tags/*"].max_new_tokens == 5
    bad = [
        {"type": "string", "x-max-tokens": 4},  # not x-generate
        {"type": "string", "x-generate": True, "x-max-tokens": 0},
        {"type": "string", "x-levels": {"1": "a"}},  # not a score field
        {"type": "integer", "minimum": 0, "maximum": 2, "x-levels": {"5": "a"}},
        {"type": "integer", "minimum": 0, "maximum": 2, "x-levels": {"1": ""}},
        {"type": "string", "x-other": 1},
    ]
    for sub in bad:
        with pytest.raises(SchemaError):
            compile_schema({"type": "object", "properties": {"f": sub}})