😏
This commit is contained in:
Executable
+447
@@ -0,0 +1,447 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small JSON-schema subset used for worker output contracts.
|
||||
|
||||
The project intentionally avoids a runtime dependency on jsonschema. Profiles
|
||||
may use the supported, auditable subset documented in PROFILE_SCHEMA.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from mmo_util import (
|
||||
strict_json_decoder,
|
||||
strict_json_loads,
|
||||
valid_absolute_uri,
|
||||
validate_json_unicode,
|
||||
)
|
||||
|
||||
SUPPORTED_TYPES = {"object", "array", "string", "integer", "number", "boolean", "null"}
|
||||
SUPPORTED_FORMATS = {"uri", "date", "date-time"}
|
||||
_RFC3339_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", re.ASCII)
|
||||
_RFC3339_DATE_TIME = re.compile(
|
||||
r"([0-9]{4})-([0-9]{2})-([0-9]{2})"
|
||||
r"[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})"
|
||||
r"(?:\.([0-9]+))?"
|
||||
r"(?:([Zz])|([+-])([0-9]{2}):([0-9]{2}))",
|
||||
re.ASCII,
|
||||
)
|
||||
|
||||
|
||||
def _is_finite_number(value: Any) -> bool:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return False
|
||||
# Converting an arbitrarily large JSON integer to float can overflow even
|
||||
# though the integer itself is finite.
|
||||
return isinstance(value, int) or math.isfinite(value)
|
||||
|
||||
|
||||
def _is_integer_number(value: Any) -> bool:
|
||||
"""Return whether a JSON number has a zero fractional part."""
|
||||
|
||||
return _is_finite_number(value) and (
|
||||
isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
||||
)
|
||||
|
||||
|
||||
def _json_value_key(value: Any) -> tuple[Any, ...] | None:
|
||||
"""Return a strict JSON value key using JSON Schema equality rules."""
|
||||
|
||||
if value is None:
|
||||
return ("null",)
|
||||
if isinstance(value, bool):
|
||||
return ("boolean", value)
|
||||
if isinstance(value, int):
|
||||
return ("number", value)
|
||||
if isinstance(value, float):
|
||||
return ("number", value) if math.isfinite(value) else None
|
||||
if isinstance(value, str):
|
||||
return ("string", value)
|
||||
if isinstance(value, list):
|
||||
items = [_json_value_key(item) for item in value]
|
||||
if any(item is None for item in items):
|
||||
return None
|
||||
return ("array", tuple(items))
|
||||
if isinstance(value, Mapping):
|
||||
if not all(isinstance(key, str) for key in value):
|
||||
return None
|
||||
members = [(key, _json_value_key(value[key])) for key in sorted(value)]
|
||||
if any(item is None for _key, item in members):
|
||||
return None
|
||||
return ("object", tuple(members))
|
||||
return None
|
||||
|
||||
|
||||
def validate_schema_definition(schema: Any, path: str = "$") -> list[str]:
|
||||
errors: list[str] = []
|
||||
if isinstance(schema, bool):
|
||||
return errors
|
||||
if not isinstance(schema, Mapping):
|
||||
return [f"{path}: schema must be an object or boolean"]
|
||||
if not all(isinstance(key, str) for key in schema):
|
||||
errors.append(f"{path}: schema keyword names must be strings")
|
||||
schema_type = schema.get("type")
|
||||
if schema_type is not None:
|
||||
if isinstance(schema_type, str):
|
||||
if schema_type not in SUPPORTED_TYPES:
|
||||
errors.append(f"{path}.type: unsupported type {schema_type!r}")
|
||||
elif isinstance(schema_type, list):
|
||||
if not schema_type:
|
||||
errors.append(f"{path}.type: array cannot be empty")
|
||||
invalid = [
|
||||
item
|
||||
for item in schema_type
|
||||
if not isinstance(item, str) or item not in SUPPORTED_TYPES
|
||||
]
|
||||
if invalid:
|
||||
errors.append(f"{path}.type: unsupported types {invalid!r}")
|
||||
elif len(schema_type) != len(set(schema_type)):
|
||||
errors.append(f"{path}.type: array contains duplicate types")
|
||||
else:
|
||||
errors.append(f"{path}.type: must be a string or array")
|
||||
for keyword in ("oneOf", "anyOf", "allOf"):
|
||||
if keyword in schema:
|
||||
value = schema[keyword]
|
||||
if not isinstance(value, list) or not value:
|
||||
errors.append(f"{path}.{keyword}: must be a non-empty array")
|
||||
else:
|
||||
for index, item in enumerate(value):
|
||||
errors.extend(validate_schema_definition(item, f"{path}.{keyword}[{index}]"))
|
||||
for keyword in ("not", "if", "then", "else"):
|
||||
if keyword in schema:
|
||||
errors.extend(validate_schema_definition(schema[keyword], f"{path}.{keyword}"))
|
||||
if ("then" in schema or "else" in schema) and "if" not in schema:
|
||||
errors.append(f"{path}: then/else requires if")
|
||||
if "properties" in schema:
|
||||
properties = schema["properties"]
|
||||
if not isinstance(properties, Mapping):
|
||||
errors.append(f"{path}.properties: must be an object")
|
||||
else:
|
||||
for key, value in properties.items():
|
||||
if not isinstance(key, str):
|
||||
errors.append(f"{path}.properties: property names must be strings")
|
||||
continue
|
||||
errors.extend(validate_schema_definition(value, f"{path}.properties.{key}"))
|
||||
if "items" in schema:
|
||||
errors.extend(validate_schema_definition(schema["items"], f"{path}.items"))
|
||||
if "required" in schema:
|
||||
required = schema["required"]
|
||||
if not (isinstance(required, list) and all(isinstance(item, str) for item in required)):
|
||||
errors.append(f"{path}.required: must be an array of strings")
|
||||
elif len(required) != len(set(required)):
|
||||
errors.append(f"{path}.required: contains duplicate property names")
|
||||
if "enum" in schema:
|
||||
enum = schema["enum"]
|
||||
if not isinstance(enum, list) or not enum:
|
||||
errors.append(f"{path}.enum: must be a non-empty array")
|
||||
else:
|
||||
keys = [_json_value_key(item) for item in enum]
|
||||
if any(key is None for key in keys):
|
||||
errors.append(f"{path}.enum: values must be valid finite JSON values")
|
||||
elif len(keys) != len(set(keys)):
|
||||
errors.append(f"{path}.enum: values must be unique")
|
||||
if "const" in schema and _json_value_key(schema["const"]) is None:
|
||||
errors.append(f"{path}.const: must be a valid finite JSON value")
|
||||
for keyword in ("$schema", "$id", "title", "description"):
|
||||
if keyword in schema and not isinstance(schema[keyword], str):
|
||||
errors.append(f"{path}.{keyword}: must be a string")
|
||||
for keyword in ("minLength", "maxLength", "minItems", "maxItems"):
|
||||
if keyword in schema:
|
||||
value = schema[keyword]
|
||||
if not _is_integer_number(value) or value < 0:
|
||||
errors.append(f"{path}.{keyword}: must be a non-negative integer")
|
||||
for minimum, maximum in (("minLength", "maxLength"), ("minItems", "maxItems")):
|
||||
if (
|
||||
_is_integer_number(schema.get(minimum))
|
||||
and _is_integer_number(schema.get(maximum))
|
||||
and schema[minimum] > schema[maximum]
|
||||
):
|
||||
errors.append(f"{path}: {minimum} exceeds {maximum}")
|
||||
for keyword in ("minimum", "maximum"):
|
||||
if keyword in schema:
|
||||
value = schema[keyword]
|
||||
if not _is_finite_number(value):
|
||||
errors.append(f"{path}.{keyword}: must be a finite number")
|
||||
if (
|
||||
isinstance(schema.get("minimum"), (int, float))
|
||||
and not isinstance(schema.get("minimum"), bool)
|
||||
and isinstance(schema.get("maximum"), (int, float))
|
||||
and not isinstance(schema.get("maximum"), bool)
|
||||
and schema["minimum"] > schema["maximum"]
|
||||
):
|
||||
errors.append(f"{path}: minimum exceeds maximum")
|
||||
if "uniqueItems" in schema and not isinstance(schema["uniqueItems"], bool):
|
||||
errors.append(f"{path}.uniqueItems: must be boolean")
|
||||
if "additionalProperties" in schema:
|
||||
additional = schema["additionalProperties"]
|
||||
if isinstance(additional, Mapping):
|
||||
errors.extend(validate_schema_definition(additional, f"{path}.additionalProperties"))
|
||||
elif not isinstance(additional, bool):
|
||||
errors.append(f"{path}.additionalProperties: must be boolean or a schema")
|
||||
if "pattern" in schema:
|
||||
pattern = schema["pattern"]
|
||||
if not isinstance(pattern, str):
|
||||
errors.append(f"{path}.pattern: must be a string")
|
||||
else:
|
||||
try:
|
||||
re.compile(pattern)
|
||||
except re.error as exc:
|
||||
errors.append(f"{path}.pattern: invalid regular expression: {exc}")
|
||||
if "format" in schema:
|
||||
schema_format = schema["format"]
|
||||
if not isinstance(schema_format, str) or schema_format not in SUPPORTED_FORMATS:
|
||||
errors.append(f"{path}.format: must be one of {sorted(SUPPORTED_FORMATS)}")
|
||||
known = {
|
||||
"$schema",
|
||||
"$id",
|
||||
"title",
|
||||
"description",
|
||||
"type",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"items",
|
||||
"enum",
|
||||
"const",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"pattern",
|
||||
"oneOf",
|
||||
"anyOf",
|
||||
"allOf",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"format",
|
||||
}
|
||||
unknown = sorted(key for key in schema if isinstance(key, str) and key not in known)
|
||||
if unknown:
|
||||
errors.append(f"{path}: unsupported schema keywords: {', '.join(unknown)}")
|
||||
return errors
|
||||
|
||||
|
||||
def _types(schema: Mapping[str, Any]) -> set[str] | None:
|
||||
value = schema.get("type")
|
||||
if value is None:
|
||||
return None
|
||||
return {value} if isinstance(value, str) else set(value)
|
||||
|
||||
|
||||
def _matches_type(value: Any, expected: str) -> bool:
|
||||
if expected == "null":
|
||||
return value is None
|
||||
if expected == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if expected == "integer":
|
||||
return _is_integer_number(value)
|
||||
if expected == "number":
|
||||
return _is_finite_number(value)
|
||||
if expected == "string":
|
||||
return isinstance(value, str)
|
||||
if expected == "array":
|
||||
return isinstance(value, list)
|
||||
if expected == "object":
|
||||
return isinstance(value, Mapping)
|
||||
return False
|
||||
|
||||
|
||||
def _rfc3339_month_days(year: int, month: int) -> int:
|
||||
if not 1 <= month <= 12:
|
||||
return 0
|
||||
leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
||||
month_days = (31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
return month_days[month - 1]
|
||||
|
||||
|
||||
def _valid_rfc3339_date(year: int, month: int, day: int) -> bool:
|
||||
return 1 <= day <= _rfc3339_month_days(year, month)
|
||||
|
||||
|
||||
def _shift_rfc3339_date(year: int, month: int, day: int, day_delta: int) -> tuple[int, int, int]:
|
||||
"""Shift a valid RFC 3339 date by the at-most-one-day offset boundary."""
|
||||
|
||||
if day_delta == -1:
|
||||
if day > 1:
|
||||
return year, month, day - 1
|
||||
if month > 1:
|
||||
month -= 1
|
||||
else:
|
||||
year -= 1
|
||||
month = 12
|
||||
return year, month, _rfc3339_month_days(year, month)
|
||||
if day_delta == 1:
|
||||
if day < _rfc3339_month_days(year, month):
|
||||
return year, month, day + 1
|
||||
if month < 12:
|
||||
return year, month + 1, 1
|
||||
return year + 1, 1, 1
|
||||
return year, month, day
|
||||
|
||||
|
||||
def _matches_rfc3339_date(value: str) -> bool:
|
||||
matched = _RFC3339_DATE.fullmatch(value)
|
||||
return matched is not None and _valid_rfc3339_date(
|
||||
int(matched.group(1)), int(matched.group(2)), int(matched.group(3))
|
||||
)
|
||||
|
||||
|
||||
def _matches_rfc3339_date_time(value: str) -> bool:
|
||||
matched = _RFC3339_DATE_TIME.fullmatch(value)
|
||||
if matched is None:
|
||||
return False
|
||||
year, month, day, hour, minute, second = map(int, matched.groups()[:6])
|
||||
if not _valid_rfc3339_date(year, month, day) or hour > 23 or minute > 59 or second > 60:
|
||||
return False
|
||||
if matched.group(8) is not None:
|
||||
offset_minutes = 0
|
||||
else:
|
||||
offset_hour = int(matched.group(10))
|
||||
offset_minute = int(matched.group(11))
|
||||
if offset_hour > 23 or offset_minute > 59:
|
||||
return False
|
||||
offset_minutes = offset_hour * 60 + offset_minute
|
||||
if matched.group(9) == "-":
|
||||
offset_minutes = -offset_minutes
|
||||
if second == 60:
|
||||
day_delta, utc_minute = divmod(hour * 60 + minute - offset_minutes, 24 * 60)
|
||||
utc_year, utc_month, utc_day = _shift_rfc3339_date(year, month, day, day_delta)
|
||||
# RFC 3339 section 5.7 allows :60 only at the end of a month in UTC.
|
||||
# The local spelling may fall on the adjacent date after applying its
|
||||
# numeric offset, so validate the shifted UTC calendar date.
|
||||
return utc_minute == 23 * 60 + 59 and utc_day == _rfc3339_month_days(utc_year, utc_month)
|
||||
return True
|
||||
|
||||
|
||||
def _matches_format(value: str, schema_format: str) -> bool:
|
||||
if schema_format == "uri":
|
||||
return valid_absolute_uri(value)
|
||||
if schema_format == "date":
|
||||
return _matches_rfc3339_date(value)
|
||||
if schema_format == "date-time":
|
||||
return _matches_rfc3339_date_time(value)
|
||||
return False
|
||||
|
||||
|
||||
def validate_instance(
|
||||
value: Any,
|
||||
schema: Mapping[str, Any] | bool,
|
||||
path: str = "$",
|
||||
) -> list[str]:
|
||||
if schema is True:
|
||||
return []
|
||||
if schema is False:
|
||||
return [f"{path}: value is rejected by false schema"]
|
||||
errors: list[str] = []
|
||||
allowed_types = _types(schema)
|
||||
if allowed_types and not any(_matches_type(value, item) for item in allowed_types):
|
||||
return [
|
||||
f"{path}: expected {' or '.join(sorted(allowed_types))}, got {type(value).__name__}"
|
||||
]
|
||||
if "const" in schema:
|
||||
value_key = _json_value_key(value)
|
||||
const_key = _json_value_key(schema["const"])
|
||||
if value_key is None or const_key is None or value_key != const_key:
|
||||
errors.append(f"{path}: expected constant {schema['const']!r}")
|
||||
if "enum" in schema:
|
||||
value_key = _json_value_key(value)
|
||||
enum_keys = {_json_value_key(item) for item in schema["enum"]}
|
||||
if value_key is None or value_key not in enum_keys:
|
||||
errors.append(f"{path}: value {value!r} is not in the allowed enum")
|
||||
if "oneOf" in schema:
|
||||
matches = [not validate_instance(value, item, path) for item in schema["oneOf"]]
|
||||
if sum(matches) != 1:
|
||||
errors.append(f"{path}: value must match exactly one oneOf schema")
|
||||
if "anyOf" in schema:
|
||||
if not any(not validate_instance(value, item, path) for item in schema["anyOf"]):
|
||||
errors.append(f"{path}: value does not match any anyOf schema")
|
||||
if "allOf" in schema:
|
||||
for item in schema["allOf"]:
|
||||
errors.extend(validate_instance(value, item, path))
|
||||
if "not" in schema and not validate_instance(value, schema["not"], path):
|
||||
errors.append(f"{path}: value matches prohibited not schema")
|
||||
if "if" in schema:
|
||||
branch = "then" if not validate_instance(value, schema["if"], path) else "else"
|
||||
if branch in schema:
|
||||
errors.extend(validate_instance(value, schema[branch], path))
|
||||
if isinstance(value, str):
|
||||
if len(value) < int(schema.get("minLength", 0)):
|
||||
errors.append(f"{path}: string is shorter than minLength")
|
||||
if "maxLength" in schema and len(value) > int(schema["maxLength"]):
|
||||
errors.append(f"{path}: string is longer than maxLength")
|
||||
if "pattern" in schema and not re.search(str(schema["pattern"]), value):
|
||||
errors.append(f"{path}: string does not match required pattern")
|
||||
if "format" in schema and not _matches_format(value, str(schema["format"])):
|
||||
errors.append(f"{path}: string does not match {schema['format']} format")
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
if "minimum" in schema and value < schema["minimum"]:
|
||||
errors.append(f"{path}: value is below minimum")
|
||||
if "maximum" in schema and value > schema["maximum"]:
|
||||
errors.append(f"{path}: value is above maximum")
|
||||
if isinstance(value, list):
|
||||
if len(value) < int(schema.get("minItems", 0)):
|
||||
errors.append(f"{path}: array has fewer than minItems")
|
||||
if "maxItems" in schema and len(value) > int(schema["maxItems"]):
|
||||
errors.append(f"{path}: array has more than maxItems")
|
||||
if schema.get("uniqueItems"):
|
||||
serialized = [_json_value_key(item) for item in value]
|
||||
if any(item is None for item in serialized):
|
||||
errors.append(f"{path}: array items must be valid finite JSON values")
|
||||
elif len(serialized) != len(set(serialized)):
|
||||
errors.append(f"{path}: array items must be unique")
|
||||
item_schema = schema.get("items")
|
||||
if isinstance(item_schema, (Mapping, bool)):
|
||||
for index, item in enumerate(value):
|
||||
errors.extend(validate_instance(item, item_schema, f"{path}[{index}]"))
|
||||
if isinstance(value, Mapping):
|
||||
required = schema.get("required", [])
|
||||
for key in required:
|
||||
if key not in value:
|
||||
errors.append(f"{path}: required property {key!r} is missing")
|
||||
properties = schema.get("properties", {})
|
||||
additional = schema.get("additionalProperties", True)
|
||||
for key, item in value.items():
|
||||
if key in properties:
|
||||
errors.extend(validate_instance(item, properties[key], f"{path}.{key}"))
|
||||
elif additional is False:
|
||||
errors.append(f"{path}: additional property {key!r} is not allowed")
|
||||
elif isinstance(additional, Mapping):
|
||||
errors.extend(validate_instance(item, additional, f"{path}.{key}"))
|
||||
return errors
|
||||
|
||||
|
||||
def extract_json_document(text: str) -> tuple[Any | None, str | None]:
|
||||
stripped = text.strip()
|
||||
candidates = [stripped]
|
||||
fenced = re.findall(r"```(?:json)?\s*(.*?)```", stripped, flags=re.IGNORECASE | re.DOTALL)
|
||||
candidates.extend(item.strip() for item in fenced)
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
return strict_json_loads(candidate), None
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
decoder = strict_json_decoder()
|
||||
for match in re.finditer(r"[\[{]", stripped):
|
||||
try:
|
||||
value, end = decoder.raw_decode(stripped[match.start() :])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
try:
|
||||
validate_json_unicode(value)
|
||||
except ValueError:
|
||||
continue
|
||||
remaining = stripped[match.start() + end :].strip()
|
||||
if not remaining or remaining.startswith("```"):
|
||||
return value, None
|
||||
return None, "no valid JSON document was found in the final worker response"
|
||||
Reference in New Issue
Block a user