From b95e0e42499f7be166067e5dd88c5f0503425978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Wed, 11 Nov 2026 09:20:40 +0300 Subject: [PATCH 1/3] feat(schema): union types, numeric const/enum equality and unsupported-keyword warnings Bring the opt-in payload validator to the conformance v1.10.0 payload_schema_keywords behaviour: type unions, integral floats as integers, maxLength, const/enum compared by mathematical value, and one registration warning per unsupported keyword (babelqueue.schema logger and provider.warnings). Vendors conformance v1.10.0. --- CHANGELOG.md | 19 ++ src/babelqueue/schema.py | 175 +++++++++++++++++-- tests/conformance/CONFORMANCE_VERSION | 2 +- tests/conformance/manifest.json | 243 +++++++++++++++++++++++++- tests/test_conformance.py | 1 + tests/test_schema.py | 179 +++++++++++++++++++ tests/test_schema_conformance.py | 39 ++++- 7 files changed, 644 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45cd898..0b7807a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ The envelope wire format is versioned separately by `meta.schema_version` ## [Unreleased] +Brings the opt-in payload validator (ADR-0024) to the K-17 Draft-07 subset behaviour pinned by conformance +v1.10.0 (section `payload_schema_keywords`). Only the optional `data` validation changes; the envelope stays at +`schema_version: 1` and nothing on the wire changes. + +### Added +- `type` may be a **union list** (`["string", "null"]`): a value is valid when it matches any listed type, and the + keywords of the matched type then apply. `integer` accepts every integral number (`1.0`, `Decimal("2")`). +- `maxLength` (Unicode code points, like `minLength`). +- **Registration warnings for unsupported keywords.** `MapProvider` / `DirProvider` report one `SchemaWarning(pointer, + keyword)` per keyword outside the subset (`pattern`, `format`, `allOf`, `$ref`, `maximum`, ...) when the schema is + first processed: logged on the `babelqueue.schema` logger and kept in the provider's `warnings` map. The verdict is + unchanged (the keyword is evaluated as absent). Annotation keywords (`$schema`, `$id`, `title`, `description`, + `examples`, `default`, `$comment`, `x-*`) never warn. `unsupported_keywords(schema)` is the pure helper. + +### Changed +- **`const` / `enum` compare numbers by mathematical value**, recursively inside arrays and objects: `1`, `1.0` and + `Decimal("1")` are equal, while `True` is never `1` and `"1"` is never `1`. Before, `const: 1` rejected `1.0`. +- Vendored conformance suite updated to v1.10.0. + ## [1.25.0] - 2026-11-01 ### Changed (dashboard-breaking) β€” `babelqueue.otel` follows the OpenTelemetry messaging semantic conventions v1.44.0 (ADR-0038) diff --git a/src/babelqueue/schema.py b/src/babelqueue/schema.py index f8b933f..188e1f8 100644 --- a/src/babelqueue/schema.py +++ b/src/babelqueue/schema.py @@ -24,23 +24,32 @@ handler unchanged. The validator is a small subset of JSON Schema (draft-07) whose verdicts match the Go and -PHP validators and babelqueue-registry's ``compat`` linter: ``type``, ``required``, -``properties``, ``additionalProperties``, ``items``, ``enum``, ``const``, ``minLength``, -``minimum``. Unknown keywords are ignored. Zero dependencies (stdlib only). +PHP validators and babelqueue-registry's ``compat`` linter: ``type`` (a name or a union list +such as ``["string", "null"]``), ``required``, ``properties``, ``additionalProperties``, +``items``, ``enum``, ``const``, ``minLength``, ``maxLength``, ``minimum``. ``const`` / ``enum`` +compare numbers by mathematical value (``1 == 1.0``; ``True`` is never ``1``), also inside arrays +and objects. A keyword outside the subset never changes the verdict (it is evaluated as absent), +but registering the schema with a :class:`MapProvider` / :class:`DirProvider` reports one +:class:`SchemaWarning` per occurrence through the ``babelqueue.schema`` logger and the provider's +``warnings`` map (conformance ``payload_schema_keywords``). Zero dependencies (stdlib only). """ from __future__ import annotations import functools import json +import logging import os import threading -from typing import Any, Callable, List, Mapping, NamedTuple, Optional, Protocol, runtime_checkable +from decimal import Decimal +from typing import Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Protocol, runtime_checkable from .exceptions import InvalidPayloadError Handler = Callable[..., None] +logger = logging.getLogger("babelqueue.schema") + @runtime_checkable class SchemaProvider(Protocol): @@ -55,6 +64,17 @@ class MapProvider: def __init__(self, schemas: Mapping[str, Mapping[str, Any]]) -> None: self._schemas: dict[str, Mapping[str, Any]] = dict(schemas) + self._warnings: Dict[str, List["SchemaWarning"]] = {} + for urn, schema in self._schemas.items(): + found = report_unsupported(urn, schema) + if found: + self._warnings[urn] = found + + @property + def warnings(self) -> Dict[str, List["SchemaWarning"]]: + """urn -> the unsupported-keyword warnings its schema produced at registration (only URNs + that produced at least one). Read-only: a copy; each was also logged on ``babelqueue.schema``.""" + return {urn: list(found) for urn, found in self._warnings.items()} @classmethod def from_json(cls, raw: Mapping[str, str]) -> "MapProvider": @@ -88,6 +108,7 @@ def __init__(self, manifest_path: str) -> None: self._files: dict[str, str] = {} self._cache: dict[str, Mapping[str, Any]] = {} self._lock = threading.Lock() + self._warnings: Dict[str, List["SchemaWarning"]] = {} entries = manifest.get("schemas") or [] if isinstance(entries, list): @@ -99,6 +120,13 @@ def __init__(self, manifest_path: str) -> None: if isinstance(urn, str) and urn and isinstance(file, str) and file: self._files[urn] = file + @property + def warnings(self) -> Dict[str, List["SchemaWarning"]]: + """urn -> the unsupported-keyword warnings reported when its schema was first loaded. + Read-only: a copy.""" + with self._lock: + return {urn: list(found) for urn, found in self._warnings.items()} + def schema_for(self, urn: str) -> Optional[Mapping[str, Any]]: with self._lock: cached = self._cache.get(urn) @@ -113,6 +141,9 @@ def schema_for(self, urn: str) -> Optional[Mapping[str, Any]]: if not isinstance(decoded, dict): raise ValueError(f"schema: invalid schema for {urn!r} ({file})") self._cache[urn] = decoded + found = report_unsupported(urn, decoded) + if found: + self._warnings[urn] = found return decoded @@ -151,6 +182,9 @@ def wrapped(*args: Any) -> None: return wrapped +_KNOWN_TYPES = ("object", "array", "string", "integer", "number", "boolean", "null") + + def validate_schema(schema: Mapping[str, Any], value: Any, path: str = "") -> Optional[str]: """The first violation of ``value`` against a (subset) JSON Schema node, or None.""" if "const" in schema and not _equal(value, schema["const"]): @@ -161,6 +195,39 @@ def validate_schema(schema: Mapping[str, Any], value: Any, path: str = "") -> Op return _violation(path, "not_in_enum") typ = schema.get("type") + if isinstance(typ, str): + return _check_type(schema, typ, value, path) + if isinstance(typ, list): + # Union (Draft-07): valid when the value matches ANY listed type, and then the keywords of + # the matched type apply. Names outside the known types can never match; a list with no + # known name constrains nothing, like an unknown single ``type``. + names = [t for t in typ if isinstance(t, str) and t in _KNOWN_TYPES] + if not names: + return None + for name in names: + if _matches_type(name, value): + return _check_type(schema, name, value, path) + return _violation(path, "wrong_type") + return None + + +def _matches_type(typ: str, value: Any) -> bool: + if typ == "object": + return isinstance(value, Mapping) + if typ == "array": + return isinstance(value, list) + if typ == "string": + return isinstance(value, str) + if typ == "integer": + return _is_integer(value) + if typ == "number": + return _is_number(value) + if typ == "boolean": + return isinstance(value, bool) + return value is None # "null" + + +def _check_type(schema: Mapping[str, Any], typ: str, value: Any, path: str) -> Optional[str]: if typ == "object": return _check_object(schema, value, path) if typ == "array": @@ -169,8 +236,11 @@ def validate_schema(schema: Mapping[str, Any], value: Any, path: str = "") -> Op if not isinstance(value, str): return _violation(path, "not_a_string") min_len = schema.get("minLength") - if isinstance(min_len, (int, float)) and len(value) < int(min_len): + if isinstance(min_len, (int, float, Decimal)) and not isinstance(min_len, bool) and len(value) < int(min_len): return _violation(path, "below_min_length") + max_len = schema.get("maxLength") + if isinstance(max_len, (int, float, Decimal)) and not isinstance(max_len, bool) and len(value) > int(max_len): + return _violation(path, "above_max_length") return None if typ == "integer": if not _is_integer(value): @@ -230,27 +300,46 @@ def _check_array(schema: Mapping[str, Any], value: Any, path: str) -> Optional[s def _check_minimum(schema: Mapping[str, Any], value: Any, path: str) -> Optional[str]: minimum = schema.get("minimum") - if isinstance(minimum, (int, float)) and not isinstance(minimum, bool) and float(value) < float(minimum): + if isinstance(minimum, (int, float, Decimal)) and not isinstance(minimum, bool) and value < minimum: return _violation(path, "below_minimum") return None def _is_integer(value: Any) -> bool: + """``integer`` accepts every number whose mathematical value is integral (``1.0`` too).""" if isinstance(value, bool): return False if isinstance(value, int): return True - return isinstance(value, float) and value.is_integer() + if isinstance(value, float): + return value.is_integer() + if isinstance(value, Decimal): + return value.is_finite() and value == value.to_integral_value() + return False def _is_number(value: Any) -> bool: - return isinstance(value, (int, float)) and not isinstance(value, bool) + # int / float / Decimal (what a ``parse_float=Decimal`` decoder yields); never a bool. + return isinstance(value, (int, float, Decimal)) and not isinstance(value, bool) def _equal(a: Any, b: Any) -> bool: - # Type-aware equality so True != 1 and an integer const never matches a float value, - # matching the strict comparisons in the Go and PHP validators. - return type(a) is type(b) and a == b + """JSON equality for ``const`` / ``enum``: numbers compare by mathematical value (``1`` == + ``1.0`` == ``Decimal("1")``), recursively inside arrays and objects (object key order is + irrelevant); ``True`` is never ``1``, ``"1"`` is never ``1`` and ``None`` equals only ``None``.""" + if isinstance(a, bool) or isinstance(b, bool): + return isinstance(a, bool) and isinstance(b, bool) and a == b + if _is_number(a) and _is_number(b): + return bool(a == b) + if a is None or b is None: + return a is None and b is None + if isinstance(a, str) and isinstance(b, str): + return a == b + if isinstance(a, Mapping) and isinstance(b, Mapping): + return a.keys() == b.keys() and all(_equal(a[k], b[k]) for k in a) + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + return len(a) == len(b) and all(_equal(x, y) for x, y in zip(a, b)) + return False def _violation(path: str, reason: str) -> str: @@ -261,6 +350,70 @@ def _join(path: str, key: str) -> str: return key if path == "" else f"{path}.{key}" +class SchemaWarning(NamedTuple): + """One keyword outside the supported subset, found while registering a schema. ``pointer`` is + the RFC 6901 JSON Pointer, within the schema document, of the schema object carrying + ``keyword`` (``""`` for the root, ``/properties/``, ``/items``, + ``/additionalProperties``). The keyword never changes the verdict; this is only a notice.""" + + pointer: str + keyword: str + + +_SUPPORTED_KEYWORDS = frozenset( + ("type", "required", "properties", "additionalProperties", "items", "enum", "const", + "minLength", "maxLength", "minimum") +) +_ANNOTATION_KEYWORDS = frozenset(("$schema", "$id", "title", "description", "examples", "default", "$comment")) + + +def unsupported_keywords(schema: Mapping[str, Any]) -> List[SchemaWarning]: + """Every keyword in ``schema`` outside the supported subset, one entry per occurrence. + + Annotation keywords (``$schema``, ``$id``, ``title``, ``description``, ``examples``, + ``default``, ``$comment`` and anything starting with ``x-``) never count. The walk descends + only into the values of ``properties``, into ``items`` and into ``additionalProperties`` when + each is an object; it never descends into an unsupported keyword's value, so ``allOf`` or + ``definitions`` is one warning and what is inside produces none. + """ + found: List[SchemaWarning] = [] + _walk_unsupported(schema, "", found) + return found + + +def _escape_pointer(token: str) -> str: + return token.replace("~", "~0").replace("/", "~1") + + +def _walk_unsupported(node: Any, pointer: str, out: List[SchemaWarning]) -> None: + if not isinstance(node, Mapping): + return + for key in node: + name = str(key) + if name in _SUPPORTED_KEYWORDS or name in _ANNOTATION_KEYWORDS or name.startswith("x-"): + continue + out.append(SchemaWarning(pointer, name)) + properties = node.get("properties") + if isinstance(properties, Mapping): + for name, sub in properties.items(): + _walk_unsupported(sub, f"{pointer}/properties/{_escape_pointer(str(name))}", out) + _walk_unsupported(node.get("items"), f"{pointer}/items", out) + _walk_unsupported(node.get("additionalProperties"), f"{pointer}/additionalProperties", out) + + +def report_unsupported(urn: str, schema: Mapping[str, Any]) -> List[SchemaWarning]: + """Find the unsupported keywords of ``schema`` (registered under ``urn``), log one warning each + on the ``babelqueue.schema`` logger and return them.""" + found = unsupported_keywords(schema) + for warning in found: + logger.warning( + "Schema for %s uses keyword %r at %r, which the payload validator does not support; " + "it is ignored (the verdict is unchanged).", + urn, warning.keyword, warning.pointer, + ) + return found + + class SensitivePath(NamedTuple): """One property a schema marked ``x-gdpr-sensitive`` (ADR-0030), located by its dotted path from the schema root. Array elements use the ``field[]`` segment the validator and registry diff --git a/tests/conformance/CONFORMANCE_VERSION b/tests/conformance/CONFORMANCE_VERSION index f8e233b..81c871d 100644 --- a/tests/conformance/CONFORMANCE_VERSION +++ b/tests/conformance/CONFORMANCE_VERSION @@ -1 +1 @@ -1.9.0 +1.10.0 diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index 70a44fc..637f8dd 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "conformance_version": "1.9.0", + "conformance_version": "1.10.0", "sections": { "cases": { "scope": "core" @@ -50,6 +50,9 @@ "payload_schema_unicode": { "scope": "core" }, + "payload_schema_keywords": { + "scope": "core" + }, "trim": { "scope": "core" }, @@ -1075,6 +1078,244 @@ } ] }, + "payload_schema_keywords": { + "description": "Draft-07 subset keyword semantics (ADR-0024, K-17). The subset is exactly `type`, `required`, `properties`, `additionalProperties`, `items`, `enum`, `const`, `minLength`, `maxLength`, `minimum`; every SDK's optional payload validator MUST agree on both the verdict and the registration warnings below (GR-5). The wire envelope stays frozen: this governs the opt-in `data` validation only. Shape: `cases` is a list of `{ name, schema, data, valid, warnings? }`; unlike `payload_schema` each case carries its own `schema`. A runner registers `schema`, collects the warnings the registration produces, then validates `data` and compares `valid`. Three rules are pinned. (1) UNION TYPES: `type` may be an array of type names (`[\"string\", \"null\"]`); a value is valid when it matches ANY listed type. `integer` accepts every number whose mathematical value is integral, so `1.0` and `1e0` and `1e2` are integers and `1.5` is not (Draft-07). `number` accepts any number, `boolean` accepts only `true`/`false` (never `0`/`1`), `null` accepts only `null`. (2) NUMERIC EQUALITY in `const` and `enum`: numbers compare by mathematical value, so `1`, `1.0` and `1e0` are equal and `2.5` equals `2.50`; the rule applies recursively inside arrays and objects (objects compare by key set and values, key order is irrelevant; arrays compare element by element in order). `true` is not `1`, `false` is not `0`, `\"1\"` is not `1`, and `null` equals only `null`. Write the JSON number literal as the case does (`1.0`, `1e0`, `2.50`): a runner MUST NOT reject or normalise it before comparing. (3) UNSUPPORTED KEYWORDS: a keyword outside the subset does NOT change the verdict (the schema is evaluated as if the keyword were absent) but registering the schema produces exactly one warning per unsupported keyword occurrence, `{ pointer, keyword }`. `pointer` is the RFC 6901 JSON Pointer, within the schema document, of the schema object that carries the keyword: the root is the empty string `\"\"`, then `/properties/`, `/items`, `/additionalProperties` (property names are escaped `~` -> `~0`, `/` -> `~1`). The walk descends only into the values of `properties`, into `items` and into `additionalProperties` when each is a JSON object; it never descends into the value of an unsupported keyword (`allOf`, `definitions`, ...), so keywords inside such a value produce no warning, and the names inside `properties` are property names, never keywords. Annotation keywords are not unsupported and never warn: `$schema`, `$id`, `title`, `description`, `examples`, `default`, `$comment`, and every keyword whose name starts with `x-` (for example the ADR-0030 `x-gdpr-sensitive`). The expected warnings of a case are the `warnings` field compared as a SET of `{ pointer, keyword }` pairs (order is irrelevant, the message text is the SDK's own and is never compared); an absent `warnings` means the expected set is empty. Because rule 3 evaluates an unsupported keyword as absent, a case whose schema uses a keyword that a full Draft-07 validator enforces (`allOf`, `$ref`, `maximum`, `pattern`, ...) has the subset's verdict, not Draft-07's; for that reason the reference Draft-07 cross-check of `ci.yml` does not cover this section.", + "cases": [ + { "name": "union-string-null-accepts-string", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": "abc" }, "valid": true }, + { "name": "union-string-null-accepts-null", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": null }, "valid": true }, + { "name": "union-string-null-rejects-number", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": 5 }, "valid": false }, + { "name": "union-string-null-rejects-boolean", "schema": { "type": "object", "properties": { "v": { "type": ["string", "null"] } } }, "data": { "v": false }, "valid": false }, + { "name": "union-integer-null-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "union-integer-null-accepts-1e2", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": 1e2 }, "valid": true }, + { "name": "union-integer-null-rejects-1.5", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": 1.5 }, "valid": false }, + { "name": "union-integer-null-accepts-null", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "null"] } } }, "data": { "v": null }, "valid": true }, + { "name": "union-integer-boolean-rejects-numeric-string", "schema": { "type": "object", "properties": { "v": { "type": ["integer", "boolean"] } } }, "data": { "v": "1" }, "valid": false }, + { "name": "type-integer-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "type": "integer" } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "type-integer-rejects-1.5", "schema": { "type": "object", "properties": { "v": { "type": "integer" } } }, "data": { "v": 1.5 }, "valid": false }, + { "name": "union-nested-properties-accepts-null", "schema": { "type": "object", "properties": { "user": { "type": "object", "properties": { "email": { "type": ["string", "null"] }, "age": { "type": ["integer", "null"], "minimum": 0 } } } } }, "data": { "user": { "email": null, "age": 30.0 } }, "valid": true }, + { "name": "union-nested-properties-rejects-wrong-type", "schema": { "type": "object", "properties": { "user": { "type": "object", "properties": { "email": { "type": ["string", "null"] } } } } }, "data": { "user": { "email": 7 } }, "valid": false }, + { "name": "union-nested-minimum-still-applies", "schema": { "type": "object", "properties": { "user": { "type": "object", "properties": { "age": { "type": ["integer", "null"], "minimum": 0 } } } } }, "data": { "user": { "age": -1 } }, "valid": false }, + { "name": "union-items-accepts-member-types", "schema": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": ["string", "number"] } } } }, "data": { "list": ["a", 2, 3.5] }, "valid": true }, + { "name": "union-items-rejects-boolean-member", "schema": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": ["string", "number"] } } } }, "data": { "list": ["a", true] }, "valid": false }, + { "name": "const-number-accepts-1", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1 }, "valid": true }, + { "name": "const-number-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "const-number-accepts-1e0", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1e0 }, "valid": true }, + { "name": "const-number-rejects-true", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": true }, "valid": false }, + { "name": "const-number-rejects-string-1", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": "1" }, "valid": false }, + { "name": "const-number-rejects-1.5", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": 1.5 }, "valid": false }, + { "name": "const-number-rejects-null", "schema": { "type": "object", "properties": { "v": { "const": 1 } } }, "data": { "v": null }, "valid": false }, + { "name": "const-1.0-accepts-integer-1", "schema": { "type": "object", "properties": { "v": { "const": 1.0 } } }, "data": { "v": 1 }, "valid": true }, + { "name": "const-true-rejects-1", "schema": { "type": "object", "properties": { "v": { "const": true } } }, "data": { "v": 1 }, "valid": false }, + { "name": "const-false-rejects-0", "schema": { "type": "object", "properties": { "v": { "const": false } } }, "data": { "v": 0 }, "valid": false }, + { "name": "const-null-accepts-null", "schema": { "type": "object", "properties": { "v": { "const": null } } }, "data": { "v": null }, "valid": true }, + { "name": "const-null-rejects-zero", "schema": { "type": "object", "properties": { "v": { "const": null } } }, "data": { "v": 0 }, "valid": false }, + { "name": "const-null-rejects-empty-string", "schema": { "type": "object", "properties": { "v": { "const": null } } }, "data": { "v": "" }, "valid": false }, + { "name": "enum-numbers-accepts-2.50", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": 2.50 }, "valid": true }, + { "name": "enum-numbers-accepts-1.0", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": 1.0 }, "valid": true }, + { "name": "enum-numbers-accepts-exponent-form", "schema": { "type": "object", "properties": { "v": { "enum": [10, 2.5] } } }, "data": { "v": 1e1 }, "valid": true }, + { "name": "enum-numbers-rejects-3", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": 3 }, "valid": false }, + { "name": "enum-numbers-rejects-string-1", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": "1" }, "valid": false }, + { "name": "enum-numbers-rejects-true", "schema": { "type": "object", "properties": { "v": { "enum": [1, 2.5] } } }, "data": { "v": true }, "valid": false }, + { "name": "enum-mixed-keeps-booleans-apart", "schema": { "type": "object", "properties": { "v": { "enum": [true, null, 0] } } }, "data": { "v": 1 }, "valid": false }, + { "name": "enum-mixed-accepts-0.0-for-listed-0", "schema": { "type": "object", "properties": { "v": { "enum": [true, null, 0] } } }, "data": { "v": 0.0 }, "valid": true }, + { "name": "const-object-accepts-1.0-in-nested-array", "schema": { "const": { "a": [1] } }, "data": { "a": [1.0] }, "valid": true }, + { "name": "const-object-rejects-true-in-nested-array", "schema": { "const": { "a": [1] } }, "data": { "a": [true] }, "valid": false }, + { "name": "const-object-rejects-longer-array", "schema": { "const": { "a": [1] } }, "data": { "a": [1, 2] }, "valid": false }, + { "name": "const-object-rejects-extra-key", "schema": { "const": { "a": [1] } }, "data": { "a": [1], "b": 1 }, "valid": false }, + { "name": "const-object-ignores-key-order", "schema": { "const": { "a": 1, "b": [2, { "c": 3 }] } }, "data": { "b": [2.0, { "c": 3e0 }], "a": 1.0 }, "valid": true }, + { "name": "const-object-keeps-array-order", "schema": { "const": { "a": [1, 2] } }, "data": { "a": [2, 1] }, "valid": false }, + { "name": "enum-object-accepts-1.0-inside-object", "schema": { "type": "object", "properties": { "v": { "enum": [{ "n": 1 }, { "n": 2 }] } } }, "data": { "v": { "n": 1.0 } }, "valid": true }, + { "name": "enum-object-rejects-string-inside-object", "schema": { "type": "object", "properties": { "v": { "enum": [{ "n": 1 }, { "n": 2 }] } } }, "data": { "v": { "n": "1" } }, "valid": false }, + { + "name": "unsupported-pattern-format-maxitems-valid-unchanged", + "schema": { "type": "object", "properties": { "email": { "type": "string", "pattern": "^.+@.+$", "format": "email" }, "tags": { "type": "array", "items": { "type": "string" }, "maxItems": 1 } } }, + "data": { "email": "not-an-email", "tags": ["a", "b"] }, + "valid": true, + "warnings": [ + { "pointer": "/properties/email", "keyword": "pattern" }, + { "pointer": "/properties/email", "keyword": "format" }, + { "pointer": "/properties/tags", "keyword": "maxItems" } + ] + }, + { + "name": "unsupported-keywords-supported-ones-still-enforced", + "schema": { "type": "object", "properties": { "email": { "type": "string", "pattern": "^.+@.+$", "format": "email" } } }, + "data": { "email": 12 }, + "valid": false, + "warnings": [ + { "pointer": "/properties/email", "keyword": "pattern" }, + { "pointer": "/properties/email", "keyword": "format" } + ] + }, + { + "name": "unsupported-root-keyword", + "schema": { "type": "object", "minProperties": 2 }, + "data": { "a": 1 }, + "valid": true, + "warnings": [{ "pointer": "", "keyword": "minProperties" }] + }, + { + "name": "unsupported-maximum-is-not-enforced", + "schema": { "type": "object", "properties": { "n": { "type": "integer", "minimum": 1, "maximum": 5 } } }, + "data": { "n": 10 }, + "valid": true, + "warnings": [{ "pointer": "/properties/n", "keyword": "maximum" }] + }, + { + "name": "unsupported-in-items-schema", + "schema": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "integer", "multipleOf": 2 } } } }, + "data": { "list": [1, 2, 3] }, + "valid": true, + "warnings": [{ "pointer": "/properties/list/items", "keyword": "multipleOf" }] + }, + { + "name": "unsupported-in-additional-properties-schema", + "schema": { "type": "object", "additionalProperties": { "type": "string", "pattern": "^x" } }, + "data": { "a": "y" }, + "valid": true, + "warnings": [{ "pointer": "/additionalProperties", "keyword": "pattern" }] + }, + { + "name": "unsupported-deeply-nested", + "schema": { "type": "object", "properties": { "a": { "type": "object", "properties": { "b": { "type": "array", "items": { "type": "object", "properties": { "c": { "type": "string", "pattern": "^z" } } } } } } } }, + "data": { "a": { "b": [{ "c": "q" }] } }, + "valid": true, + "warnings": [{ "pointer": "/properties/a/properties/b/items/properties/c", "keyword": "pattern" }] + }, + { + "name": "unsupported-pointer-escapes-property-names", + "schema": { "type": "object", "properties": { "a/b": { "type": "string", "pattern": "x" }, "m~n": { "type": "string", "format": "x" } } }, + "data": { "a/b": "q", "m~n": "q" }, + "valid": true, + "warnings": [ + { "pointer": "/properties/a~1b", "keyword": "pattern" }, + { "pointer": "/properties/m~0n", "keyword": "format" } + ] + }, + { + "name": "unsupported-container-is-one-warning-and-not-descended", + "schema": { "type": "object", "properties": { "a": { "type": "string" } }, "allOf": [{ "type": "integer", "pattern": "x" }], "definitions": { "s": { "type": "string", "format": "x" } } }, + "data": { "a": "s" }, + "valid": true, + "warnings": [ + { "pointer": "", "keyword": "allOf" }, + { "pointer": "", "keyword": "definitions" } + ] + }, + { + "name": "unsupported-ref-is-ignored", + "schema": { "type": "object", "properties": { "a": { "$ref": "#/definitions/s" } }, "definitions": { "s": { "type": "string" } } }, + "data": { "a": 5 }, + "valid": true, + "warnings": [ + { "pointer": "", "keyword": "definitions" }, + { "pointer": "/properties/a", "keyword": "$ref" } + ] + }, + { + "name": "unsupported-same-keyword-at-two-places-warns-twice", + "schema": { "type": "object", "pattern": "x", "properties": { "a": { "type": "string", "pattern": "x" } } }, + "data": { "a": "s" }, + "valid": true, + "warnings": [ + { "pointer": "", "keyword": "pattern" }, + { "pointer": "/properties/a", "keyword": "pattern" } + ] + }, + { + "name": "annotations-never-warn", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "urn:example:order", + "title": "Order", + "description": "An order.", + "$comment": "annotation only", + "examples": [{ "order_id": 1 }], + "default": {}, + "type": "object", + "properties": { + "order_id": { "type": "integer", "title": "Order id", "description": "The id.", "$comment": "c", "default": 0, "examples": [1] } + } + }, + "data": { "order_id": 7 }, + "valid": true + }, + { + "name": "x-prefixed-keywords-never-warn", + "schema": { + "type": "object", + "x-owner": "billing", + "properties": { + "email": { "type": "string", "x-gdpr-sensitive": true }, + "tags": { "type": "array", "items": { "type": "string", "x-gdpr-sensitive": { "category": "pii" } } } + }, + "additionalProperties": { "type": "string", "x-custom": 1 } + }, + "data": { "email": "a@b.c", "tags": ["x"], "extra": "s" }, + "valid": true + }, + { + "name": "annotation-and-unsupported-mixed-warns-only-for-unsupported", + "schema": { "type": "object", "title": "T", "x-gdpr-sensitive": true, "properties": { "email": { "type": "string", "description": "d", "x-gdpr-sensitive": true, "format": "email" } } }, + "data": { "email": "q" }, + "valid": true, + "warnings": [{ "pointer": "/properties/email", "keyword": "format" }] + }, + { + "name": "property-names-are-not-keywords", + "schema": { "type": "object", "properties": { "title": { "type": "string" }, "pattern": { "type": "string" }, "format": { "type": "string" }, "x-gdpr-sensitive": { "type": "string" } } }, + "data": { "title": "t", "pattern": "p", "format": "f", "x-gdpr-sensitive": "s" }, + "valid": true + }, + { + "name": "all-supported-keywords-produce-no-warnings", + "schema": { + "type": "object", + "required": ["name", "score", "kind"], + "properties": { + "name": { "type": "string", "minLength": 2, "maxLength": 5 }, + "score": { "type": ["number", "null"], "minimum": 0 }, + "kind": { "enum": ["a", "b"] }, + "version": { "const": 1 }, + "tags": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "data": { "name": "abc", "score": 0, "kind": "a", "version": 1.0, "tags": ["x"] }, + "valid": true + }, + { + "name": "max-length-ascii-boundary-valid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "abc" }, + "valid": true + }, + { + "name": "max-length-turkish-3cp-6-bytes-boundary-valid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "ğüş" }, + "valid": true + }, + { + "name": "max-length-emoji-3cp-6-utf16-units-boundary-valid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "πŸ˜€πŸ˜€πŸ˜€" }, + "valid": true + }, + { + "name": "max-length-emoji-4cp-max3-invalid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 3 } } }, + "data": { "v": "πŸ˜€πŸ˜€πŸ˜€πŸ˜€" }, + "valid": false + }, + { + "name": "max-length-combining-2cp-1-grapheme-max1-invalid", + "schema": { "type": "object", "properties": { "v": { "type": "string", "maxLength": 1 } } }, + "data": { "v": "e\u0301" }, + "valid": false + } + ] + }, "trim": { "description": "ADR-0032 \u00a75 (normative): the ASCII whitespace set is space, \\t, \\n, \\r, \\f, \\v; U+00A0, U+2003 and U+3000 are NOT whitespace and are part of the value. Scope: `core`. Every case has a `mode`. `make`: call the producer's `make` with `urn` (and `trace_id` when given); the URN and the trace_id are trimmed with the ASCII set, so `expect` is the resulting `job` / `trace_id`, or `\"reject\"` when the URN is empty after trimming. `decode`: decode `file`; `valid: true` means it succeeds with `expect.urn` as the resolved identity and a warning iff `expect_warning` (a blank string `job` falls back to `urn` silently, ADR-0032 \u00a76); `valid: false` means decode raises. `accepts`: `accepts(file)` MUST equal `expect_accepts` (a blank `trace_id` makes it false) while decode itself still succeeds when `expect_decode_ok` is true (an older producer's message without a usable trace_id is tolerated). `reemit`: decode, increment `attempts`, re-encode (retry/DLQ/redrive/relay) and assert `expect_preserved` (JSON Pointer -> value) byte for byte: re-emit never trims. Note on tension with urn-naming section 2: the `make` cases that use a Unicode-space or space-padded URN pin only the ASCII-trim blank rule (what `make` does with such an input); they do not endorse such strings as well-formed URNs, which urn-naming section 2 constrains separately.", "cases": [ diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 51541b5..f9254ab 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -89,6 +89,7 @@ def test_cases(self) -> None: "extension_write": [("test_behaviour_conformance", "ExtensionWriteConformanceTest", "test_extension_write_cases")], "encode_reject": [("test_behaviour_conformance", "EncodeRejectConformanceTest", "test_encode_reject_cases")], "payload_schema_unicode": [("test_behaviour_conformance", "PayloadSchemaUnicodeConformanceTest", "test_unicode_length_cases")], + "payload_schema_keywords": [("test_schema_conformance", "PayloadKeywordsConformanceTest", "test_payload_keywords_cases")], "trim": [("test_behaviour_conformance", "TrimConformanceTest", "test_trim_cases")], "job_type": [("test_behaviour_conformance", "JobTypeConformanceTest", "test_job_type_cases")], "traceparent": [("test_behaviour_conformance", "TraceparentConformanceTest", "test_traceparent_cases")], diff --git a/tests/test_schema.py b/tests/test_schema.py index 9bc79b5..209ef6e 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,16 +1,20 @@ from __future__ import annotations import json +import logging import os import tempfile import unittest +from decimal import Decimal from typing import List from babelqueue.exceptions import InvalidPayloadError from babelqueue.schema import ( DirProvider, MapProvider, + SchemaWarning, check, + unsupported_keywords, validate, validate_schema, wrap, @@ -68,6 +72,181 @@ def test_scalar_types(self) -> None: self.assertEqual(valid, violation is None, f"{src} / {value!r}: {violation}") +class UnionTypeTest(unittest.TestCase): + def test_union_accepts_any_listed_type(self) -> None: + schema = {"type": ["string", "null"]} + self.assertIsNone(validate_schema(schema, "abc")) + self.assertIsNone(validate_schema(schema, None)) + self.assertIsNotNone(validate_schema(schema, 5)) + self.assertIsNotNone(validate_schema(schema, False)) + + def test_integer_accepts_integral_floats_and_decimals(self) -> None: + for value in (1, 1.0, 100.0, Decimal("2"), Decimal("1E+2")): + self.assertIsNone(validate_schema({"type": "integer"}, value), repr(value)) + for value in (1.5, Decimal("1.5"), True, "1", float("inf")): + self.assertIsNotNone(validate_schema({"type": "integer"}, value), repr(value)) + self.assertIsNone(validate_schema({"type": ["integer", "null"]}, 1.0)) + self.assertIsNotNone(validate_schema({"type": ["integer", "boolean"]}, "1")) + + def test_keywords_of_the_matched_type_still_apply(self) -> None: + schema = {"type": ["integer", "null"], "minimum": 0} + self.assertIsNone(validate_schema(schema, None)) + self.assertIsNotNone(validate_schema(schema, -1)) + obj = {"type": ["object", "null"], "required": ["a"], "properties": {"a": {"type": "string"}}} + self.assertIsNone(validate_schema(obj, None)) + self.assertIsNone(validate_schema(obj, {"a": "x"})) + self.assertIn("a", validate_schema(obj, {}) or "") + self.assertIsNotNone(validate_schema(obj, {"a": 1})) + text = {"type": ["string", "null"], "minLength": 2, "maxLength": 3} + self.assertIsNone(validate_schema(text, None)) + self.assertIsNotNone(validate_schema(text, "a")) + self.assertIsNotNone(validate_schema(text, "abcd")) + + def test_union_of_unknown_names_constrains_nothing(self) -> None: + self.assertIsNone(validate_schema({"type": ["nope"]}, 1)) + self.assertIsNone(validate_schema({"type": []}, 1)) + self.assertIsNotNone(validate_schema({"type": ["nope", "string"]}, 1)) + + def test_length_bounds_truncate_fractional_values_like_go_and_php(self) -> None: + self.assertIsNone(validate_schema({"type": "string", "minLength": 2.9}, "ab")) + self.assertIsNotNone(validate_schema({"type": "string", "minLength": 2.9}, "a")) + self.assertIsNone(validate_schema({"type": "string", "maxLength": 2.9}, "ab")) + self.assertIsNotNone(validate_schema({"type": "string", "maxLength": 2.9}, "abc")) + + def test_provider_warnings_are_read_only_copies(self) -> None: + with self.assertLogs("babelqueue.schema", level=logging.WARNING): + provider = MapProvider({"urn:a": {"type": "object", "minProperties": 1}}) + with self.assertRaises(AttributeError): + provider.warnings = {} # type: ignore[misc] + provider.warnings["urn:a"].clear() + provider.warnings.clear() + self.assertEqual([SchemaWarning("", "minProperties")], provider.warnings["urn:a"]) + + def test_max_length_counts_code_points(self) -> None: + schema = {"type": "string", "maxLength": 3} + self.assertIsNone(validate_schema(schema, "\U0001F600" * 3)) + self.assertIsNotNone(validate_schema(schema, "\U0001F600" * 4)) + self.assertIsNotNone(validate_schema({"type": "string", "maxLength": 1}, "e\u0301")) + + +class NumericEqualityTest(unittest.TestCase): + def test_const_and_enum_compare_numbers_by_value_across_number_types(self) -> None: + one_like = (1, 1.0, Decimal("1"), Decimal("1.00")) + for const in one_like: + for value in one_like: + self.assertIsNone(validate_schema({"const": const}, value), f"{const!r} vs {value!r}") + self.assertIsNone(validate_schema({"enum": [1, 2.5]}, Decimal("2.50"))) + self.assertIsNone(validate_schema({"const": 2.5}, Decimal("2.5"))) + self.assertIsNotNone(validate_schema({"const": 1}, 1.5)) + self.assertIsNotNone(validate_schema({"enum": [1, 2.5]}, 3)) + + def test_booleans_strings_and_null_stay_apart_from_numbers(self) -> None: + self.assertIsNotNone(validate_schema({"const": 1}, True)) + self.assertIsNotNone(validate_schema({"const": True}, 1)) + self.assertIsNotNone(validate_schema({"const": False}, 0)) + self.assertIsNotNone(validate_schema({"const": 1}, "1")) + self.assertIsNotNone(validate_schema({"const": 1}, None)) + self.assertIsNotNone(validate_schema({"const": None}, 0)) + self.assertIsNotNone(validate_schema({"const": None}, "")) + self.assertIsNone(validate_schema({"const": None}, None)) + self.assertIsNotNone(validate_schema({"enum": [True, None, 0]}, 1)) + self.assertIsNone(validate_schema({"enum": [True, None, 0]}, 0.0)) + + def test_equality_is_recursive_and_key_order_free(self) -> None: + self.assertIsNone(validate_schema({"const": {"a": [1]}}, {"a": [1.0]})) + self.assertIsNone(validate_schema({"const": {"a": [1]}}, {"a": [Decimal("1")]})) + self.assertIsNotNone(validate_schema({"const": {"a": [1]}}, {"a": [True]})) + self.assertIsNotNone(validate_schema({"const": {"a": [1]}}, {"a": [1, 2]})) + self.assertIsNotNone(validate_schema({"const": {"a": [1]}}, {"a": [1], "b": 1})) + self.assertIsNotNone(validate_schema({"const": {"a": [1, 2]}}, {"a": [2, 1]})) + self.assertIsNone( + validate_schema({"const": {"a": 1, "b": [2, {"c": 3}]}}, {"b": [2.0, {"c": Decimal("3")}], "a": 1.0}) + ) + self.assertIsNone(validate_schema({"enum": [{"n": 1}, {"n": 2}]}, {"n": 1.0})) + self.assertIsNotNone(validate_schema({"enum": [{"n": 1}, {"n": 2}]}, {"n": "1"})) + + +class UnsupportedKeywordTest(unittest.TestCase): + def test_reports_one_warning_per_occurrence_with_pointers(self) -> None: + schema = { + "type": "object", + "pattern": "x", + "properties": { + "a/b": {"type": "string", "pattern": "x"}, + "m~n": {"type": "string", "format": "x"}, + "list": {"type": "array", "items": {"type": "integer", "multipleOf": 2}}, + }, + "additionalProperties": {"type": "string", "maxItems": 1}, + } + self.assertEqual( + { + SchemaWarning("", "pattern"), + SchemaWarning("/properties/a~1b", "pattern"), + SchemaWarning("/properties/m~0n", "format"), + SchemaWarning("/properties/list/items", "multipleOf"), + SchemaWarning("/additionalProperties", "maxItems"), + }, + set(unsupported_keywords(schema)), + ) + self.assertEqual(5, len(unsupported_keywords(schema))) + + def test_annotations_and_property_names_never_warn(self) -> None: + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", "$id": "x", "title": "t", + "description": "d", "examples": [], "default": {}, "$comment": "c", "x-owner": "billing", + "type": "object", + "properties": { + "title": {"type": "string", "x-gdpr-sensitive": True}, + "pattern": {"type": "string"}, + "format": {"type": "string"}, + }, + } + self.assertEqual([], unsupported_keywords(schema)) + + def test_unsupported_container_is_one_warning_and_not_descended(self) -> None: + schema = {"type": "object", "allOf": [{"pattern": "x"}], "definitions": {"s": {"format": "x"}}} + self.assertEqual( + {SchemaWarning("", "allOf"), SchemaWarning("", "definitions")}, set(unsupported_keywords(schema)) + ) + + def test_unsupported_keywords_do_not_change_the_verdict(self) -> None: + schema = {"type": "string", "pattern": "^a", "format": "email", "maximum": 1} + self.assertIsNone(validate_schema(schema, "nope")) + self.assertIsNotNone(validate_schema(schema, 12)) + + def test_map_provider_reports_on_registration_through_the_logger(self) -> None: + urn = "urn:babel:orders:created" + with self.assertLogs("babelqueue.schema", level=logging.WARNING) as logs: + provider = MapProvider( + {urn: {"type": "object", "properties": {"e": {"type": "string", "format": "email"}}}, + "urn:babel:clean": {"type": "object"}} + ) + self.assertEqual({urn: [SchemaWarning("/properties/e", "format")]}, provider.warnings) + self.assertEqual(1, len(logs.output)) + self.assertIn(urn, logs.output[0]) + self.assertIn("format", logs.output[0]) + + def test_map_provider_from_json_reports_too(self) -> None: + with self.assertLogs("babelqueue.schema", level=logging.WARNING): + provider = MapProvider.from_json({"urn:a": '{"type":"object","minProperties":1}'}) + self.assertEqual([SchemaWarning("", "minProperties")], provider.warnings["urn:a"]) + + def test_dir_provider_reports_once_when_the_schema_is_first_loaded(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, "o.json"), "w", encoding="utf-8") as fh: + fh.write('{"type":"object","properties":{"n":{"type":"integer","maximum":5}}}') + manifest = os.path.join(tmp, "registry.json") + with open(manifest, "w", encoding="utf-8") as fh: + fh.write('{"schemas":[{"urn":"urn:o","schema":"o.json"}]}') + provider = DirProvider(manifest) + self.assertEqual({}, provider.warnings) + with self.assertLogs("babelqueue.schema", level=logging.WARNING) as logs: + provider.schema_for("urn:o") + provider.schema_for("urn:o") # cached: no second report + self.assertEqual(1, len(logs.output)) + self.assertEqual({"urn:o": [SchemaWarning("/properties/n", "maximum")]}, provider.warnings) + + class ProviderTest(unittest.TestCase): def test_map_provider_from_json(self) -> None: provider = MapProvider.from_json({"urn:babel:orders:created": ORDERS}) diff --git a/tests/test_schema_conformance.py b/tests/test_schema_conformance.py index d7364f2..e86c42a 100644 --- a/tests/test_schema_conformance.py +++ b/tests/test_schema_conformance.py @@ -1,10 +1,12 @@ from __future__ import annotations import json +import logging import unittest from pathlib import Path +from typing import Set, Tuple -from babelqueue.schema import validate_schema +from babelqueue.schema import MapProvider, validate_schema MANIFEST = Path(__file__).parent / "conformance" / "manifest.json" @@ -31,5 +33,40 @@ def test_payload_cases_match_across_sdks(self) -> None: self.assertEqual(case["valid"], valid, f"case {case['name']}") +class PayloadKeywordsConformanceTest(unittest.TestCase): + """The ``payload_schema_keywords`` section (conformance 1.10.0, K-17): union ``type``, + numeric equality in ``const`` / ``enum`` and the registration warning for keywords outside the + subset. Each case carries its own schema; the runner registers it, collects the warnings + (compared as a SET of ``(pointer, keyword)``), then validates ``data`` and compares ``valid``.""" + + def test_payload_keywords_cases(self) -> None: + if not MANIFEST.is_file(): + self.skipTest("vendored conformance suite not present") + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + section = manifest.get("payload_schema_keywords") + if not isinstance(section, dict): + self.skipTest("manifest has no payload_schema_keywords section") + + cases = section["cases"] + self.assertTrue(cases) + urn = "urn:babel:conformance:keywords" + for case in cases: + with self.subTest(case=case["name"]): + with self.assertLogs("babelqueue.schema", level=logging.WARNING) as logs: + # assertLogs fails when nothing is logged, so a never-warning case logs a probe. + logging.getLogger("babelqueue.schema").warning("probe") + provider = MapProvider({urn: case["schema"]}) + warnings = provider.warnings.get(urn, []) + got: Set[Tuple[str, str]] = {(w.pointer, w.keyword) for w in warnings} + expected = {(w["pointer"], w["keyword"]) for w in case.get("warnings", [])} + self.assertEqual(expected, got, f"case {case['name']}: warnings") + # one warning per occurrence, each one logged exactly once + self.assertEqual(len(warnings), len(got), f"case {case['name']}: duplicate warnings") + self.assertEqual(len(warnings), len(logs.output) - 1, f"case {case['name']}: logged") + + valid = validate_schema(case["schema"], case["data"]) is None + self.assertEqual(case["valid"], valid, f"case {case['name']}") + + if __name__ == "__main__": unittest.main() From a02d4c1503b59992cc69fca718457213085ce5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Wed, 11 Nov 2026 10:53:28 +0300 Subject: [PATCH 2/3] fix(schema): ignore non-finite minLength/maxLength instead of raising --- CHANGELOG.md | 4 ++++ src/babelqueue/schema.py | 24 ++++++++++++++++++++---- tests/test_schema.py | 8 ++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b7807a..a62f3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ v1.10.0 (section `payload_schema_keywords`). Only the optional `data` validation unchanged (the keyword is evaluated as absent). Annotation keywords (`$schema`, `$id`, `title`, `description`, `examples`, `default`, `$comment`, `x-*`) never warn. `unsupported_keywords(schema)` is the pure helper. +### Fixed +- A non-finite `minLength` / `maxLength` (`NaN`, `Β±Infinity`; reachable through `json.loads`, which accepts them) is + ignored instead of raising `ValueError` / `OverflowError` out of `validate_schema`. Matches the other SDKs. + ### Changed - **`const` / `enum` compare numbers by mathematical value**, recursively inside arrays and objects: `1`, `1.0` and `Decimal("1")` are equal, while `True` is never `1` and `"1"` is never `1`. Before, `const: 1` rejected `1.0`. diff --git a/src/babelqueue/schema.py b/src/babelqueue/schema.py index 188e1f8..3cdb89d 100644 --- a/src/babelqueue/schema.py +++ b/src/babelqueue/schema.py @@ -39,6 +39,7 @@ import functools import json import logging +import math import os import threading from decimal import Decimal @@ -235,11 +236,11 @@ def _check_type(schema: Mapping[str, Any], typ: str, value: Any, path: str) -> O if typ == "string": if not isinstance(value, str): return _violation(path, "not_a_string") - min_len = schema.get("minLength") - if isinstance(min_len, (int, float, Decimal)) and not isinstance(min_len, bool) and len(value) < int(min_len): + min_len = _length_bound(schema.get("minLength")) + if min_len is not None and len(value) < min_len: return _violation(path, "below_min_length") - max_len = schema.get("maxLength") - if isinstance(max_len, (int, float, Decimal)) and not isinstance(max_len, bool) and len(value) > int(max_len): + max_len = _length_bound(schema.get("maxLength")) + if max_len is not None and len(value) > max_len: return _violation(path, "above_max_length") return None if typ == "integer": @@ -298,6 +299,21 @@ def _check_array(schema: Mapping[str, Any], value: Any, path: str) -> Optional[s return None +def _length_bound(bound: Any) -> Optional[int]: + """``minLength`` / ``maxLength`` as an int, truncated toward zero like Go and PHP. + + A non-numeric, boolean or non-finite (NaN / +-Infinity) bound is ignored (``None``), as in the + other SDKs; ``int()`` would raise ``ValueError`` / ``OverflowError`` on those. + """ + if isinstance(bound, bool) or not isinstance(bound, (int, float, Decimal)): + return None + if isinstance(bound, float) and not math.isfinite(bound): + return None + if isinstance(bound, Decimal) and not bound.is_finite(): + return None + return int(bound) + + def _check_minimum(schema: Mapping[str, Any], value: Any, path: str) -> Optional[str]: minimum = schema.get("minimum") if isinstance(minimum, (int, float, Decimal)) and not isinstance(minimum, bool) and value < minimum: diff --git a/tests/test_schema.py b/tests/test_schema.py index 209ef6e..877c3ff 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -113,6 +113,14 @@ def test_length_bounds_truncate_fractional_values_like_go_and_php(self) -> None: self.assertIsNone(validate_schema({"type": "string", "maxLength": 2.9}, "ab")) self.assertIsNotNone(validate_schema({"type": "string", "maxLength": 2.9}, "abc")) + def test_non_finite_length_bounds_are_ignored(self) -> None: + # A NaN / Infinity bound (reachable through json.loads) never raises and never changes a verdict. + for keyword in ("minLength", "maxLength"): + for bound in (float("nan"), float("inf"), float("-inf"), Decimal("NaN"), Decimal("Infinity")): + schema = {"type": "string", keyword: bound} + for text in ("", "a", "abc"): + self.assertIsNone(validate_schema(schema, text), (keyword, bound, text)) + def test_provider_warnings_are_read_only_copies(self) -> None: with self.assertLogs("babelqueue.schema", level=logging.WARNING): provider = MapProvider({"urn:a": {"type": "object", "minProperties": 1}}) From 7eb4f7a07815b76f94cd30df1cb3197e1af22371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Wed, 11 Nov 2026 13:43:14 +0300 Subject: [PATCH 3/3] Release v1.26.0 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- src/babelqueue/__init__.py | 2 +- tests/conformance/schema/results.example.json | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e0fc02..1aa3356 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,7 @@ jobs: --health-retries 10 env: # The pinned conformance release (ADR-0032). Bump together with tests/conformance/. - BQ_CONFORMANCE_VERSION: 1.9.0 + BQ_CONFORMANCE_VERSION: 1.10.0 # sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather # than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION. BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be90877..972be4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: --health-retries 10 env: # The pinned conformance release (ADR-0032). Keep identical to ci.yml. - BQ_CONFORMANCE_VERSION: 1.9.0 + BQ_CONFORMANCE_VERSION: 1.10.0 BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849 permissions: id-token: write # PyPI Trusted Publishing (OIDC) β€” no API token needed diff --git a/CHANGELOG.md b/CHANGELOG.md index a62f3d0..6010ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The envelope wire format is versioned separately by `meta.schema_version` ## [Unreleased] +## [1.26.0] - 2026-11-11 + Brings the opt-in payload validator (ADR-0024) to the K-17 Draft-07 subset behaviour pinned by conformance v1.10.0 (section `payload_schema_keywords`). Only the optional `data` validation changes; the envelope stays at `schema_version: 1` and nothing on the wire changes. diff --git a/pyproject.toml b/pyproject.toml index c23855c..b2133ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "babelqueue" -version = "1.25.0" +version = "1.26.0" description = "Polyglot Queues, Simplified β€” the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers." readme = "README.md" requires-python = ">=3.9" diff --git a/src/babelqueue/__init__.py b/src/babelqueue/__init__.py index b68b499..99cb5f9 100644 --- a/src/babelqueue/__init__.py +++ b/src/babelqueue/__init__.py @@ -47,7 +47,7 @@ Transport, ) -__version__ = "1.25.0" +__version__ = "1.26.0" __all__ = [ "BabelQueue", diff --git a/tests/conformance/schema/results.example.json b/tests/conformance/schema/results.example.json index 736688d..d8caff0 100644 --- a/tests/conformance/schema/results.example.json +++ b/tests/conformance/schema/results.example.json @@ -1,7 +1,7 @@ { "sdk": "example-sdk/redis", "version": "1.9.0", - "conformance_version": "1.9.0", + "conformance_version": "1.10.0", "generated_at": "2026-10-18T09:30:00Z", "bindings": [ "redis",