Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,63 @@ minor versions may change public API.

## [Unreleased]

### Added

- **`KeywordContext.report(describe)`** (DESIGN.md P18): a keyword reports
an error as the lowering-IR description its `lower` emits, and the record
realizes it against the instance only if the error is rendered. Runtime
values go in as `Const` nodes; a `Binding` raises `LookupError` when
rendered. `error(message, params)` is unchanged.
- **Lowering IR for a `false` subschema** (`json_schema_engine.core.lowering`,
DESIGN.md P19): `Reject` and `RejectCheck` statements, their constructors
`reject` and `reject_check`, and `Collect(errors=True)` (`collect(binding,
errors=True)`) for the keys one summary error names.
- **Message-formatting helpers in `HelperName`**: `preview`,
`apparent_type`, `typed_preview`, `index_ranges`, `ranges`, `name_list`,
`labeled_names`, `counted_indexes`, `index_groups`, `duplicate_groups`,
`missing_names`, `missing_dependencies` and `dependency_list`, for use in
a `Fail`'s message or params and never in a condition.

### Changed

- **Error messages say what was wrong with what (DESIGN.md D13).** Every
error that names a limit now names the value that broke it, and the same
facts appear in `params` (`error_params=True`). Values render as compact
JSON, cut at 64 characters in the message and carried in full in params.
- Bounds and sizes: `must be >= 5, got 3`, `must be at least 5
characters, got "abc" (3)`, `must have at least 3 items, got 1`.
`enum` and `const` show both values: `must be one of [1, 2, 3], got 4`.
- `type`: `expected string, got 3 (integer)`. A container shows only its
type. `params.actual` is now the apparent type, so `integer` for a
mathematical integer.
- `pattern` and asserting `format`: `must match pattern "^a", got "b"`.
- `required`, `dependentRequired`, and draft-07 `dependencies` arrays
report **one error per keyword** naming every missing property:
`missing required properties "a", "c"`. Params `{"missing": [...]}`
replaces one error per name carrying `missingProperty`.
- `uniqueItems` names every group of equal items: `items are not unique:
[0, 2, 5] are equal; [1, 3] are equal`. Params `duplicates` is now a
list of index groups, not the first pair.
- `contains` names the matching indexes (`matched 2 items (0, 3)`, params
`matched`); `oneOf` names the passing branches; `anyOf` counts its
branches.
- **An applicator reports a `false` subschema once, by name (DESIGN.md
P19).** `additionalProperties: false` used to report `schema is false`
once per extra property, at each property. It now reports one error at
its own location, `additional properties "b", "c" not allowed`, with
params `properties`. The same holds for `unevaluatedProperties`,
`properties`, `patternProperties`, `propertyNames`, `dependentSchemas`,
`prefixItems`, `items`, `additionalItems`, `unevaluatedItems`
(`items not allowed from index 1: 1-4`, params `start` and `failed`),
`allOf`, `contains`, and draft-07 `dependencies`. The `false` subschema is
no longer applied, so `trace` has no node for it. A `$ref` to `false`
still reports `schema is false`. The compiled flag validator is unchanged
and still stops at the first offending key.

### Fixed

- A compiled evaluator for `oneOf: []` referred to bindings no branch had
set. It now reports the constant failure the interpreter does.
- The output guide now says how `list` and `hierarchical` relate to the
machines-oriented proposal that defines them. The proposal has no
concept of relevance, includes every unit in `hierarchical`, and makes
Expand Down
6 changes: 4 additions & 2 deletions DESIGN.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ assert result.output_document == {
"evaluationPath": "/properties/name",
"schemaLocation": "https://example.com/person#/properties/name",
"instanceLocation": "/name",
"errors": {"type": "expected string"},
"errors": {"type": "expected string, got 3 (integer)"},
}
],
}
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,17 +226,17 @@ wherever it was emitted.

## Error text

An asserting `format` reports `must match format '<name>'`, with
`params={"format": name}` under `error_params=True`:
An asserting `format` reports `must match format "<name>", got <value>`,
with `params={"format": name, "value": value}` under `error_params=True`:

```python
err_engine = create_engine(formats=FORMATS_2020_12, assert_formats=True)
err_uri = err_engine.register_schema({"format": "ipv4"}, "https://example.com/err")
outcome = err_engine.evaluate(err_uri, "nope", output="list", error_params=True)
assert outcome.valid is False
(error_unit,) = outcome.errors
assert error_unit["error"] == "must match format 'ipv4'"
assert error_unit["params"] == {"format": "ipv4"}
assert error_unit["error"] == 'must match format "ipv4", got "nope"'
assert error_unit["params"] == {"format": "ipv4", "value": "nope"}
```

## Notable rulings the suite pins
Expand Down
16 changes: 8 additions & 8 deletions docs/guide/output-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ assert result.output_document == {
"keywordLocation": "/properties/count/type",
"absoluteKeywordLocation": "https://example.com/config#/properties/count/type",
"instanceLocation": "/count",
"error": "expected integer",
"error": 'expected integer, got "nope" (string)',
}
],
}
Expand All @@ -157,7 +157,7 @@ assert result.errors == [
"evaluationPath": "/properties/count/type",
"schemaLocation": "https://example.com/config#/properties/count/type",
"inputLocation": "/count",
"error": "expected integer",
"error": 'expected integer, got "nope" (string)',
}
]
```
Expand Down Expand Up @@ -218,7 +218,7 @@ level every unit is.
result = engine.evaluate(uri, {"item": "widget", "count": "nope"}, output="list")
detail = result.output_document["details"][0]
assert detail["evaluationPath"] == "/properties/count"
assert detail["errors"] == {"type": "expected integer"}
assert detail["errors"] == {"type": 'expected integer, got "nope" (string)'}
```

## Detailed
Expand Down Expand Up @@ -302,8 +302,8 @@ params_uri = engine.register_schema(
)
result = engine.evaluate(params_uri, {"a": 1}, output="list", error_params=True)
by_keyword = {u["keyword"]: u["params"] for u in result.errors}
assert by_keyword["required"] == {"missingProperty": "b"}
assert by_keyword["enum"] == {"allowedValues": [{"a": 1, "b": 2}]}
assert by_keyword["required"] == {"missing": ["b"]}
assert by_keyword["enum"] == {"allowedValues": [{"a": 1, "b": 2}], "value": {"a": 1}}
```

## Trace
Expand Down Expand Up @@ -345,13 +345,13 @@ assert result.dropped_errors == [
"evaluationPath": "/anyOf/1/required",
"schemaLocation": "https://example.com/anyof#/anyOf/1/required",
"inputLocation": "",
"error": "missing required property 'b'",
"error": 'missing required property "b"',
}
]
losing_branch = next(
d for d in result.output_document["details"] if d["evaluationPath"] == "/anyOf/1"
)
assert losing_branch["droppedErrors"] == {"required": "missing required property 'b'"}
assert losing_branch["droppedErrors"] == {"required": 'missing required property "b"'}
```

The markers classify records, not units. Read `errors`/`annotations`
Expand All @@ -371,7 +371,7 @@ assert units[""]["valid"] is False
assert units["/anyOf/1"]["valid"] is False
assert "errors" not in units["/anyOf/1"]
assert units["/anyOf/1"]["droppedErrors"] == {
"required": "missing required property 'b'"
"required": 'missing required property "b"'
}
```

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,8 @@ dup_result = unique_engine.evaluate(
)
assert dup_result.valid is False
(dup_error,) = dup_result.errors
assert dup_error["error"] == "items at 1 and 3 are not unique"
assert dup_error["params"] == {"duplicates": [1, 3]}
assert dup_error["error"] == "items are not unique: [1, 3] are equal"
assert dup_error["params"] == {"duplicates": [[1, 3]]}
```

## `InfiniteLoopError`
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/source-positions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ and (when the underlying document reported one) `range`:
```python
result = engine.evaluate(root_uri, {}, output="list", positions=True)
assert result.valid is False
unit = next(e for e in result.errors if "'x'" in e["error"])
unit = next(e for e in result.errors if '"x"' in e["error"])
# `schemaLocation` is resource-rooted, in the *referenced* document...
assert unit["schemaLocation"] == "https://pos.example/leaf#/required"
# ...while `source` is document-rooted, in the document the loader parsed.
Expand Down
51 changes: 49 additions & 2 deletions docs/guide/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,61 @@ assert unit["evaluationPath"] == "/properties/name/$ref/minLength"
assert unit["schemaLocation"] == "https://example.com/named#/$defs/name/minLength"
assert unit["inputLocation"] == "/name"
assert unit["keyword"] == "minLength"
assert unit["params"] == {"limit": 1}
assert unit["error"] == "must be at least 1 characters"
assert unit["params"] == {"limit": 1, "value": "", "length": 0}
assert unit["error"] == 'must be at least 1 characters, got "" (0)'
```

`evaluationPath` names every keyword on the way to the failure, including
the `$ref` segment itself; `schemaLocation` is the canonical location the
reference resolved to, in the document that actually declares `minLength`.

## Reading error messages

Each message says what was required and what the instance had. Values are
shown as compact JSON, cut at 64 characters; `params` (with
`error_params=True`) carries the same facts in full, for tools.

```python
reading_uri = engine.register_schema(
{
"properties": {
"age": {"minimum": 0},
"tags": {"type": "array", "uniqueItems": True, "maxItems": 3},
"kind": {"enum": ["a", "b"]},
},
"required": ["age", "id", "name"],
"additionalProperties": False,
},
"https://example.com/reading",
)
result = engine.evaluate(
reading_uri,
{"age": -1, "tags": ["x", "y", "x", "z"], "kind": "c", "extra": True},
output="list",
error_params=True,
)
messages = {unit["keyword"]: unit["error"] for unit in result.errors}
assert messages == {
"minimum": "must be >= 0, got -1",
"uniqueItems": "items are not unique: [0, 2] are equal",
"maxItems": "must have at most 3 items, got 4",
"enum": 'must be one of ["a", "b"], got "c"',
"additionalProperties": 'additional property "extra" not allowed',
"required": 'missing required properties "id", "name"',
}
params = {unit["keyword"]: unit["params"] for unit in result.errors}
assert params["minimum"] == {"limit": 0, "value": -1}
assert params["additionalProperties"] == {"properties": ["extra"]}
assert params["required"] == {"missing": ["id", "name"]}
```

The list stays minimal. A keyword reports once, naming every missing
property or extra key, rather than once per name. An applicator such as
`properties` or `allOf` adds no error of its own when a subschema it applied
already explained the failure. The exception is a subschema that is `false`:
it explains nothing, so the applicator names the keys it rejected, as
`additionalProperties` does above.

## Boolean schemas

`True` and `False` are schemas on their own: `True` accepts everything,
Expand Down
71 changes: 64 additions & 7 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,31 @@ the `error_params` control; `keyword`/`vocabulary` are additionally absent for
a boolean `false` schema's error, which names no keyword; `source` appears with
the `positions` control.

`params` by keyword (D13). Messages show the same facts, with values cut at
64 characters; params carry them in full.

| keyword | params |
|---|---|
| `type` | `expected`, `actual` (the apparent type: `integer` for a mathematical integer), `value` |
| `enum` / `const` | `allowedValues` / `allowedValue`, `value` |
| `multipleOf` | `multipleOf`, `value` |
| `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` | `limit`, `value` |
| `minLength`, `maxLength` | `limit`, `value`, `length` |
| `minItems`, `maxItems`, `minProperties`, `maxProperties` | `limit`, `count` |
| `pattern` | `pattern`, `value` |
| `format` (asserting) | `format`, `value` |
| `required` | `missing`: the absent names |
| `dependentRequired`, `dependencies` (arrays) | `missing`: present name → its absent dependencies |
| `uniqueItems` | `duplicates`: groups of indexes of equal items |
| `contains` | `count`, `matched` (indexes), `minContains`, `maxContains` when set |
| `oneOf` | `passing` (indexes) |
| `additionalProperties`, `unevaluatedProperties`, `properties`, `propertyNames`, `dependentSchemas`, `dependencies` (with `false`) | `properties`: the names rejected |
| `patternProperties` (with `false`) | `properties`, `patterns` |
| `items`, `additionalItems` (with `false`) | `start`, `failed`: `[first, last]` index runs |
| `unevaluatedItems` (with `false`) | `start` (first unevaluated index), `failed` |
| `prefixItems`, tuple `items` (with `false` members) | `failed` |
| `allOf` (with `false` members) | `failed`: the `false` branch indexes |

`AnnotationUnit`: a `TypedDict` for one rendered annotation (a keyword's own
value at a location): `evaluationPath: str`, `schemaLocation: str`,
`inputLocation: str`, `keyword: str`, `annotation: JsonValue`, `vocabulary:
Expand Down Expand Up @@ -411,6 +436,15 @@ compiler tier.
need from `if`).
- `KeywordContext.error(message, params=None) -> None`: reports an assertion
failure with optional structured params (D13).
- `KeywordContext.report(describe) -> None`: reports an assertion failure
described in lowering IR (P18). `describe()` returns the message and params
in lowering IR, exactly as the keyword's `lower` builds them, with runtime
values as `Const` nodes (`INSTANCE` for the instance). The record realizes
the description against this instance only if the error is rendered, so a
verdict-only evaluation or a dropped error costs nothing. A `Binding`
cannot be resolved there and raises `LookupError` when rendered. The
built-in keywords whose message carries instance data report this way;
`error` remains for a constant message (`anyOf`'s, the `false` schema's).

`StaticFacts`: what one keyword occurrence says about itself from its value
alone (a frozen dataclass); the compiler tier's entire window into keyword
Expand Down Expand Up @@ -744,12 +778,25 @@ count the matches, fail when the count falls outside `[minimum, maximum]`
(`None` = unbounded); `matched`, when set, is a list binding that collects
the matching indexes.

`Collect`: a frozen dataclass: `binding: int` — bind an empty list to
accumulate dependency data.
`Collect`: a frozen dataclass: `binding: int`, `errors: bool = False` —
bind an empty list to accumulate dependency data, or, with `errors`, the
keys a `Reject` names. A dependency list is emitted only when a tracked
consumer reads it; an `errors` list only in the evaluator.

`Append`: a frozen dataclass: `binding: int`, `value: Expr`, `unique: bool
= False` — append `value` to a `Collect` binding.

`Reject`: a frozen dataclass: `binding: int`, `key: Expr`, `unique: bool =
False` — a key the keyword rejects because its subschema there is `false`.
The evaluator appends `key` to the `Collect(errors=True)` binding for one
summary error; a verdict-only artifact fails at once, exactly as a `Fail`.
The `false` subschema itself is never applied.

`RejectCheck`: a frozen dataclass: `binding: int`, `message: LowerMessage`,
`params: LowerParams | None = None` — after a sweep, one error naming every
rejected key, if there are any. A verdict-only artifact has already
returned, so it emits nothing.

`Produce`: a frozen dataclass: `value: Expr` — the keyword's dependency
data at the current cursor: the value `ctx.produce()` would carry.
Reached only along the keyword's accepting path; elided unless a tracked
Expand Down Expand Up @@ -855,7 +902,11 @@ CombineCheck`

`covers(fold, target) -> Covers`

`collect(binding) -> Collect`
`collect(binding, *, errors=False) -> Collect`

`reject(binding, key, *, unique=False) -> Reject`

`reject_check(binding, message, params=None) -> RejectCheck`

`append(binding, value, *, unique=False) -> Append`

Expand All @@ -872,8 +923,14 @@ refinement.

`HelperName`: `Literal["json_equal", "is_multiple_of",
"has_duplicate_items", "first_duplicate_pair", "length_of",
"code_point_length", "json_type_name"]` — the closed helper set `Helper`
may name.
"code_point_length", "json_type_name", "preview", "apparent_type",
"index_ranges", "name_list", "duplicate_groups", "ranges",
"missing_names", "missing_dependencies", "dependency_list",
"typed_preview", "labeled_names", "counted_indexes", "index_groups"]`
— the
closed helper set `Helper` may name. Everything after `json_type_name`
formats error messages and params (`json_schema_engine.core.messages`)
and never appears in a condition.

`CmpOp`: `Literal["<", "<=", ">", ">=", "==", "!="]`.

Expand All @@ -893,8 +950,8 @@ literal text and runtime-computed parts, exactly as `evaluate` reports it.
`evaluate` reports them.

`Stmt`: the union of every statement: `If | ForEachKey | ForEachIndex |
Fail | Apply | CombineCheck | CountRange | Collect | Append | Produce |
CoverageFold | Annotate`.
Fail | Apply | CombineCheck | CountRange | Collect | Append | Reject |
RejectCheck | Produce | CoverageFold | Annotate`.

## `json_schema_engine.compiler`

Expand Down
Loading
Loading