|
1 | 1 | # SPDX-FileCopyrightText: GitHub, Inc. |
2 | 2 | # SPDX-License-Identifier: MIT |
3 | 3 |
|
4 | | -"""Compile inline ``outputs`` schemas into Pydantic models. |
| 4 | +"""Validate task outputs against an inline JSON Schema. |
5 | 5 |
|
6 | | -A task can declare a typed output contract inline: |
| 6 | +A task can declare a typed output contract inline as a JSON Schema (Draft |
| 7 | +2020-12), authored directly in YAML: |
7 | 8 |
|
8 | 9 | .. code-block:: yaml |
9 | 10 |
|
10 | 11 | outputs: |
11 | | - functions: |
12 | | - type: list |
13 | | - items: |
14 | | - name: str |
15 | | - body: str |
16 | | -
|
17 | | -This module turns such a schema into a generated Pydantic model so a task's |
18 | | -produced value can be validated and coerced before it is passed to downstream |
19 | | -tasks as ``outputs.<id>``. |
20 | | -
|
21 | | -Supported field type specs: |
22 | | -
|
23 | | -* Scalars (as strings): ``str``, ``int``, ``float``, ``bool``, ``any`` (plus |
24 | | - the synonyms ``string``/``integer``/``number``/``boolean``). A trailing |
25 | | - ``?`` marks the field optional, e.g. ``str?``. |
26 | | -* Lists (as strings): ``list`` (list of any) or ``list[T]`` where ``T`` is a |
27 | | - scalar type name, e.g. ``list[str]``. |
28 | | -* Nested objects (as a mapping): a mapping whose keys are sub-field specs, or |
29 | | - the explicit ``{type: object, fields: {...}}`` form. |
30 | | -* Lists of objects (as a mapping): ``{type: list, items: <spec>}`` where |
31 | | - ``items`` is any spec (a scalar string, a nested object mapping, etc.). |
| 12 | + type: object |
| 13 | + properties: |
| 14 | + findings: |
| 15 | + type: array |
| 16 | + items: |
| 17 | + type: object |
| 18 | + properties: |
| 19 | + file: {type: string} |
| 20 | + severity: {type: string, enum: [low, medium, high]} |
| 21 | + required: [file, severity] |
| 22 | + required: [findings] |
| 23 | +
|
| 24 | +The task's produced value is validated against the schema before it is passed |
| 25 | +to downstream tasks as ``outputs.<id>``. Using JSON Schema (validated with the |
| 26 | +``jsonschema`` library) rather than a bespoke type language gives enums, |
| 27 | +numeric/string constraints, unions, objects with dynamic keys, and ``$ref`` |
| 28 | +reuse for free, and the schema itself is checked for well-formedness when the |
| 29 | +taskflow loads. |
32 | 30 | """ |
33 | 31 |
|
34 | 32 | from __future__ import annotations |
35 | 33 |
|
36 | | -__all__ = ["OutputSchemaError", "build_output_model", "validate_output"] |
| 34 | +__all__ = ["OutputSchemaError", "validate_output", "validate_output_schema"] |
37 | 35 |
|
38 | | -from typing import Any, Optional |
| 36 | +from typing import Any |
39 | 37 |
|
40 | | -from pydantic import BaseModel, ConfigDict, create_model |
41 | | - |
42 | | -_SCALARS: dict[str, Any] = { |
43 | | - "str": str, |
44 | | - "string": str, |
45 | | - "int": int, |
46 | | - "integer": int, |
47 | | - "float": float, |
48 | | - "number": float, |
49 | | - "bool": bool, |
50 | | - "boolean": bool, |
51 | | - "any": Any, |
52 | | -} |
| 38 | +from jsonschema import Draft202012Validator |
| 39 | +from jsonschema.exceptions import SchemaError |
53 | 40 |
|
54 | 41 |
|
55 | 42 | class OutputSchemaError(ValueError): |
56 | | - """Raised when an ``outputs`` schema is malformed or unsupported.""" |
57 | | - |
58 | | - |
59 | | -def _scalar_type(name: str, field: str) -> Any: |
60 | | - key = name.strip().lower() |
61 | | - if key not in _SCALARS: |
62 | | - raise OutputSchemaError(f"unsupported type {name!r} in field {field!r}") |
63 | | - return _SCALARS[key] |
64 | | - |
65 | | - |
66 | | -def _spec_to_type(field: str, spec: Any, model_name: str) -> tuple[Any, bool]: |
67 | | - """Resolve a field spec into ``(python_type, optional)``.""" |
68 | | - if isinstance(spec, str): |
69 | | - s = spec.strip() |
70 | | - optional = s.endswith("?") |
71 | | - if optional: |
72 | | - s = s[:-1].strip() |
73 | | - low = s.lower() |
74 | | - if low == "list": |
75 | | - return list, optional |
76 | | - if low.startswith("list[") and low.endswith("]"): |
77 | | - inner = s[5:-1].strip() |
78 | | - return list[_scalar_type(inner, field)], optional |
79 | | - return _scalar_type(s, field), optional |
80 | | - |
81 | | - if isinstance(spec, dict): |
82 | | - optional = bool(spec.get("optional", False)) |
83 | | - declared = spec.get("type") |
84 | | - if declared is None: |
85 | | - # Nested object shorthand: the mapping's keys are sub-fields. |
86 | | - return _build_model(f"{model_name}_{field}", spec), optional |
87 | | - declared = str(declared).lower() |
88 | | - if declared == "list": |
89 | | - items = spec.get("items", "any") |
90 | | - inner_type, _ = _spec_to_type(field, items, f"{model_name}_{field}") |
91 | | - return list[inner_type], optional |
92 | | - if declared == "object": |
93 | | - fields = spec.get("fields") |
94 | | - if not isinstance(fields, dict): |
95 | | - raise OutputSchemaError( |
96 | | - f"object field {field!r} requires a 'fields' mapping" |
97 | | - ) |
98 | | - return _build_model(f"{model_name}_{field}", fields), optional |
99 | | - # Scalar declared via the ``type`` key, e.g. {type: str}. |
100 | | - return _scalar_type(declared, field), optional |
101 | | - |
102 | | - raise OutputSchemaError(f"invalid type spec for field {field!r}: {spec!r}") |
103 | | - |
104 | | - |
105 | | -def _build_model(name: str, fields: Any) -> type[BaseModel]: |
106 | | - if not isinstance(fields, dict) or not fields: |
107 | | - raise OutputSchemaError(f"schema {name!r} must be a non-empty mapping of fields") |
108 | | - field_defs: dict[str, Any] = {} |
109 | | - for fname, fspec in fields.items(): |
110 | | - if not isinstance(fname, str) or not fname.isidentifier(): |
111 | | - raise OutputSchemaError(f"invalid field name {fname!r} in schema {name!r}") |
112 | | - py_type, optional = _spec_to_type(fname, fspec, name) |
113 | | - if optional: |
114 | | - field_defs[fname] = (Optional[py_type], None) |
115 | | - else: |
116 | | - field_defs[fname] = (py_type, ...) |
117 | | - return create_model(name, __config__=ConfigDict(extra="allow"), **field_defs) |
118 | | - |
119 | | - |
120 | | -def build_output_model(name: str, schema: dict[str, Any]) -> type[BaseModel]: |
121 | | - """Compile an ``outputs`` schema mapping into a Pydantic model class. |
| 43 | + """Raised when an ``outputs`` schema is not a well-formed JSON Schema.""" |
| 44 | + |
| 45 | + |
| 46 | +def validate_output_schema(schema: Any) -> None: |
| 47 | + """Check that *schema* is a well-formed JSON Schema, raising on failure. |
| 48 | +
|
| 49 | + Called at taskflow load time so a malformed output contract fails fast, |
| 50 | + before any model calls are made. |
122 | 51 |
|
123 | 52 | Raises: |
124 | | - OutputSchemaError: If the schema is malformed or uses an unsupported |
125 | | - type. Callers can use this to validate schemas at load time. |
| 53 | + OutputSchemaError: If *schema* is not a non-empty, valid JSON Schema. |
126 | 54 | """ |
127 | | - return _build_model(name, schema) |
| 55 | + if not isinstance(schema, dict) or not schema: |
| 56 | + raise OutputSchemaError("'outputs' must be a non-empty JSON Schema object") |
| 57 | + try: |
| 58 | + Draft202012Validator.check_schema(schema) |
| 59 | + except SchemaError as exc: |
| 60 | + raise OutputSchemaError(f"invalid 'outputs' schema: {exc.message}") from exc |
| 61 | + |
128 | 62 |
|
| 63 | +def validate_output(schema: dict[str, Any], value: Any) -> Any: |
| 64 | + """Validate *value* against the JSON Schema *schema* and return it unchanged. |
129 | 65 |
|
130 | | -def validate_output( |
131 | | - schema: dict[str, Any], value: Any, model_name: str = "TaskOutput" |
132 | | -) -> dict[str, Any]: |
133 | | - """Validate/coerce *value* against *schema*, returning a plain dict. |
| 66 | + JSON Schema validation does not coerce, so a value whose types do not |
| 67 | + already match the contract is a failure. This is deliberately stricter than |
| 68 | + permissive coercion: it surfaces malformed model output instead of silently |
| 69 | + reshaping it. |
134 | 70 |
|
135 | 71 | Raises: |
136 | | - OutputSchemaError: If the schema itself is invalid. |
137 | | - pydantic.ValidationError: If *value* does not satisfy the schema. |
| 72 | + OutputSchemaError: If *schema* itself is malformed. |
| 73 | + jsonschema.ValidationError: If *value* does not satisfy *schema*. |
138 | 74 | """ |
139 | | - model = build_output_model(model_name, schema) |
140 | | - obj = model.model_validate(value) |
141 | | - return obj.model_dump() |
| 75 | + validate_output_schema(schema) |
| 76 | + Draft202012Validator(schema).validate(value) |
| 77 | + return value |
0 commit comments