Coverage for yuio / json_schema.py: 99%
325 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9A simple JSON schema representation to describe configs and types.
11This module primarily used with
12:meth:`Parser.to_json_schema <yuio.parse.Parser.to_json_schema>`
13to generate config schemas used in IDEs.
15.. class:: JsonValue
17 A type alias for JSON values. Can be used as type of a config field,
18 in which case it will be parsed with the :class:`~yuio.parse.Json` parser.
21JSON types
22----------
24.. autoclass:: JsonSchemaType
25 :members:
27.. autoclass:: Ref
28 :members:
30.. autoclass:: Array
31 :members:
33.. autoclass:: Tuple
34 :members:
36.. autoclass:: Dict
37 :members:
39.. autoclass:: Null
40 :members:
42.. autoclass:: Boolean
43 :members:
45.. autoclass:: Number
46 :members:
48.. autoclass:: Integer
49 :members:
51.. autoclass:: String
52 :members:
54.. autoclass:: Any
55 :members:
57.. autoclass:: Never
58 :members:
60.. autoclass:: OneOf
61 :members:
63.. autoclass:: AllOf
64 :members:
66.. autoclass:: AnyOf
67 :members:
69.. autoclass:: Enum
70 :members:
72.. autoclass:: Object
73 :members:
75.. autoclass:: Opaque
76 :members:
78.. autoclass:: Meta
79 :members:
82Building a schema
83-----------------
85Most likely you'll get a schema from
86:meth:`Config.to_json_schema <yuio.config.Config.to_json_schema>`
87or :meth:`Parser.to_json_schema <yuio.parse.Parser.to_json_schema>`.
89To convert it to JSON value, use :meth:`JsonSchemaContext.render`, and possibly
90wrap the schema into :class:`Meta`:
92.. invisible-code-block: python
94 from yuio.config import Config
95 import yuio.json_schema
96 import json
97 class AppConfig(Config): ...
99.. code-block:: python
101 ctx = yuio.json_schema.JsonSchemaContext()
102 schema = yuio.json_schema.Meta(
103 AppConfig.to_json_schema(ctx),
104 title="Config for my application",
105 )
106 data = json.dumps(ctx.render(schema), indent=2)
108.. autoclass:: JsonSchemaContext
109 :members:
111"""
113from __future__ import annotations
115import abc
116import json
117import os
118from dataclasses import dataclass
120import yuio
121from yuio.util import dedent as _dedent
123from typing import TYPE_CHECKING
124from typing import ClassVar as _ClassVar
126if TYPE_CHECKING:
127 import typing_extensions as _t
128else:
129 from yuio import _typing as _t
131__all__ = [
132 "AllOf",
133 "Any",
134 "AnyOf",
135 "Array",
136 "Boolean",
137 "Dict",
138 "Enum",
139 "Integer",
140 "JsonSchemaContext",
141 "JsonSchemaType",
142 "JsonValue",
143 "Meta",
144 "Never",
145 "Null",
146 "Number",
147 "Object",
148 "OneOf",
149 "Opaque",
150 "Ref",
151 "String",
152 "Tuple",
153]
155T = _t.TypeVar("T")
157if _t.TYPE_CHECKING or "__YUIO_SPHINX_BUILD" in os.environ: # pragma: no cover
158 JsonValue: _t.TypeAlias = (
159 str
160 | int
161 | float
162 | None
163 | _t.Sequence["JsonValue"]
164 | _t.Mapping[str, "JsonValue"]
165 )
167else: # pragma: no cover
169 def _JsonValue(arg: T) -> T:
170 """
171 JSON value marker, used to detect JSON type hints at runtime.
173 """
175 return arg
177 JsonValue: _t.TypeAlias = _JsonValue # type: ignore
180class JsonSchemaContext:
181 """
182 Context for building schema.
184 """
186 def __init__(self):
187 self._types: dict[type, tuple[str, JsonSchemaType]] = {}
188 self._defs: dict[str, JsonSchemaType] = {}
190 def add_type(
191 self, key: _t.Any, /, name: str, make_schema: _t.Callable[[], JsonSchemaType]
192 ) -> Ref:
193 """
194 Add a new type to the ``$defs`` section.
196 :param key:
197 a python type or object for which we're building a schema. This type
198 will be used as a unique key in the ``$defs`` section.
199 :param name:
200 name of the type, will be used in the ``$defs`` section. If there are
201 two types with different `key`\\ s and the same `name`, their names
202 will be deduplicated.
203 :param make_schema:
204 a lambda that will be called if `key` wasn't added to this context before.
205 It should build and return the schema for this type.
206 :returns:
207 a :class:`Ref` type pointing to the just-added schema.
209 """
211 if key not in self._types:
212 i = ""
213 while f"{name}{i}" in self._defs:
214 i = (i or 1) + 1
215 name = f"{name}{i}"
216 schema = make_schema()
217 self._types[key] = (name, schema)
218 self._defs[name] = schema
219 else:
220 _, schema = self._types[key]
221 return Ref(f"#/$defs/{self._types[key][0]}", schema, self._types[key][0])
223 def get_type(self, ref: str) -> JsonSchemaType | None:
224 """
225 Get saved type by ``$ref``.
227 :param ref:
228 contents of the ``$ref`` anchor.
229 :returns:
230 schema that was earlier passed to :meth:`~JsonSchemaContext.add_type`.
232 """
234 return self._defs.get(ref)
236 def render(
237 self,
238 root: JsonSchemaType,
239 /,
240 *,
241 id: str | None = None,
242 ) -> JsonValue:
243 """
244 Convert schema to a value suitable for JSON serialization.
246 :returns:
247 complete JSON representation of a schema.
249 """
251 schema: dict[str, JsonValue] = {
252 "$schema": "https://json-schema.org/draft-07/schema",
253 }
254 if id:
255 schema["$id"] = id
256 schema.update(root.render())
257 schema["$defs"] = {name: ref.render() for name, ref in self._defs.items()}
258 return schema
261@dataclass(frozen=True, slots=True, init=False)
262class JsonSchemaType(abc.ABC):
263 """
264 Base class for JSON schema representation.
266 """
268 precedence: _ClassVar[int] = 3
269 """
270 Precedence, used for pretty-printing types.
272 """
274 @abc.abstractmethod
275 def render(self) -> dict[str, JsonValue]:
276 """
277 Serialize type as JSON.
279 """
281 raise NotImplementedError()
283 def remove_opaque(self) -> JsonSchemaType | None:
284 """
285 Return a new type with all instances of :class:`Opaque` removed from it.
287 This is usually used before pretty-printing type for documentation.
289 """
291 return self
293 @abc.abstractmethod
294 def pprint(self) -> str:
295 """
296 Pretty-print this type using TypeScript syntax.
298 """
300 raise NotImplementedError()
302 def __str__(self) -> str:
303 return self.pprint()
306@dataclass(frozen=True, slots=True)
307class Ref(JsonSchemaType):
308 """
309 A reference to a sub-schema.
311 Use :meth:`JsonSchemaContext.add_type` to create these.
313 """
315 ref: str
316 """
317 Referenced type.
319 """
321 item: JsonSchemaType
322 """
323 Access to the wrapped type.
325 """
327 name: str | None = None
328 """
329 Name of the referenced type, used for debug.
331 """
333 def render(self) -> dict[str, JsonValue]:
334 return {"$ref": self.ref}
336 def pprint(self) -> str:
337 return self.name or self.ref.removeprefix("#/$defs/")
340@dataclass(frozen=True, slots=True)
341class Array(JsonSchemaType):
342 """
343 An array or a set of values.
345 """
347 item: JsonSchemaType
348 """
349 Type of array elements.
351 """
353 unique_items: bool = False
354 """
355 Whether all array items should be unique.
357 """
359 def render(self) -> dict[str, JsonValue]:
360 schema: dict[str, JsonValue] = {"type": "array", "items": self.item.render()}
361 if self.unique_items:
362 schema["uniqueItems"] = True
363 return schema
365 def remove_opaque(self) -> JsonSchemaType | None:
366 item = self.item.remove_opaque()
367 return Array(item, self.unique_items) if item is not None else None
369 def pprint(self) -> str:
370 if self.item.precedence >= self.precedence:
371 return f"{self.item.pprint()}[]"
372 else:
373 return f"({self.item.pprint()})[]"
376@dataclass(frozen=True, slots=True)
377class Tuple(JsonSchemaType):
378 """
379 A tuple.
381 """
383 items: _t.Sequence[JsonSchemaType]
384 """
385 Types of tuple elements.
387 """
389 def render(self) -> dict[str, JsonValue]:
390 return {
391 "type": "array",
392 "items": [item.render() for item in self.items],
393 "minItems": len(self.items),
394 "maxItems": len(self.items),
395 "additionalItems": False,
396 }
398 def remove_opaque(self) -> JsonSchemaType | None:
399 return Tuple(
400 [
401 clear
402 for item in self.items
403 if (clear := item.remove_opaque()) is not None
404 ]
405 )
407 def pprint(self) -> str:
408 return f"[{', '.join(item.pprint() for item in self.items)}]"
411@dataclass(frozen=True, slots=True)
412class Dict(JsonSchemaType):
413 """
414 A dict. If key is string, this is represented as an object; if key is not a string,
415 this is represented as an array of pairs.
417 """
419 key: JsonSchemaType
420 """
421 Type of dict keys.
423 """
425 value: JsonSchemaType
426 """
427 Type of dict values.
429 """
431 def render(self) -> dict[str, JsonValue]:
432 schema: dict[str, JsonValue] = Array(Tuple([self.key, self.value])).render()
434 key = self.key
435 while True:
436 if isinstance(key, (Ref, Meta)):
437 key = key.item
438 elif isinstance(key, AllOf):
439 cleared = key.remove_opaque()
440 if cleared is not None:
441 key = cleared
442 else:
443 break
444 else:
445 break
447 if isinstance(key, String):
448 schema["type"] = [schema["type"], "object"]
449 schema["propertyNames"] = self.key.render()
450 schema["additionalProperties"] = self.value.render()
451 elif isinstance(key, Enum) and all(
452 isinstance(constant, str) for constant in key.constants
453 ):
454 schema["type"] = [schema["type"], "object"]
455 schema["properties"] = properties = {}
456 value_schema = self.value.render()
457 for constant in key.constants:
458 properties[constant] = value_schema
459 schema["additionalProperties"] = False
460 return schema
462 def remove_opaque(self) -> JsonSchemaType | None:
463 key = self.key.remove_opaque()
464 value = self.value.remove_opaque()
465 if key is not None and value is not None:
466 return Dict(key, value)
467 else:
468 return None
470 def pprint(self) -> str:
471 return f"{{[{self.key.pprint()}]: {self.value.pprint()}}}"
474def _is_string_like(schema: JsonSchemaType):
475 return (
476 isinstance(schema, String)
477 or isinstance(schema, Enum)
478 and any(isinstance(constant, str) for constant in schema.constants)
479 )
482@dataclass(frozen=True, slots=True)
483class Null(JsonSchemaType):
484 """
485 A ``null`` value.
487 """
489 def render(self) -> dict[str, JsonValue]:
490 return {"type": "null"}
492 def pprint(self) -> str:
493 return "null"
496@dataclass(frozen=True, slots=True)
497class Boolean(JsonSchemaType):
498 """
499 A boolean value.
501 """
503 def render(self) -> dict[str, JsonValue]:
504 return {"type": "boolean"}
506 def pprint(self) -> str:
507 return "boolean"
510@dataclass(frozen=True, slots=True)
511class Number(JsonSchemaType):
512 """
513 A numeric value.
515 """
517 def render(self) -> dict[str, JsonValue]:
518 return {"type": "number"}
520 def pprint(self) -> str:
521 return "number"
524@dataclass(frozen=True, slots=True)
525class Integer(Number):
526 """
527 An integer value.
529 """
531 def render(self) -> dict[str, JsonValue]:
532 return {"type": "integer"}
534 def pprint(self) -> str:
535 return "integer"
538@dataclass(frozen=True, slots=True)
539class String(JsonSchemaType):
540 """
541 A string value, possibly with pattern.
543 """
545 pattern: str | None = None
546 """
547 Regular expression for checking string elements.
549 """
551 def render(self) -> dict[str, JsonValue]:
552 schema: dict[str, JsonValue] = {"type": "string"}
553 if self.pattern is not None:
554 schema["pattern"] = self.pattern
555 return schema
557 def pprint(self) -> str:
558 return "string"
561@dataclass(frozen=True, slots=True)
562class Any(JsonSchemaType):
563 """
564 A value that always type checks, equivalent to schema ``true``.
566 """
568 def render(self) -> dict[str, JsonValue]:
569 return {}
571 def pprint(self) -> str:
572 return "any"
575@dataclass(frozen=True, slots=True)
576class Never(JsonSchemaType):
577 """
578 A value that never type checks, equivalent to schema ``false``.
580 """
582 def render(self) -> dict[str, JsonValue]:
583 return {"allOf": [False]}
585 def pprint(self) -> str:
586 return "never"
589@dataclass(frozen=True, slots=True, init=False)
590class OneOf(JsonSchemaType):
591 """
592 A union of possible values, equivalent to ``oneOf`` schema.
594 """
596 precedence = 2
598 items: _t.Sequence[JsonSchemaType]
599 """
600 Inner items.
602 """
604 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
605 flatten: list[JsonSchemaType] = []
606 for type in items:
607 if isinstance(type, Never):
608 pass
609 elif isinstance(type, OneOf):
610 flatten.extend(type.items)
611 else:
612 flatten.append(type)
613 if not flatten:
614 return Never()
615 elif len(flatten) == 1:
616 return flatten[0]
617 self = object.__new__(cls)
618 object.__setattr__(self, "items", flatten)
619 return self
621 def render(self) -> dict[str, JsonValue]:
622 return {"oneOf": [item.render() for item in self.items]}
624 def remove_opaque(self) -> JsonSchemaType | None:
625 items = [
626 clear for item in self.items if (clear := item.remove_opaque()) is not None
627 ]
628 if items:
629 return OneOf(items)
630 else:
631 return None
633 def pprint(self) -> str:
634 return " | ".join(
635 f"{item}" if item.precedence >= self.precedence else f"({item})"
636 for item in self.items
637 )
640@dataclass(frozen=True, slots=True, init=False)
641class AllOf(JsonSchemaType):
642 """
643 An intersection of possible values, equivalent to ``allOf`` schema.
645 """
647 precedence = 1
649 items: _t.Sequence[JsonSchemaType]
650 """
651 Inner items.
653 """
655 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
656 flatten: list[JsonSchemaType] = []
657 for type in items:
658 if isinstance(type, Never):
659 pass
660 elif isinstance(type, AllOf):
661 flatten.extend(type.items)
662 else:
663 flatten.append(type)
664 if not flatten:
665 return Never()
666 elif len(flatten) == 1:
667 return flatten[0]
668 self = object.__new__(cls)
669 object.__setattr__(self, "items", flatten)
670 return self
672 def render(self) -> dict[str, JsonValue]:
673 return {"allOf": [item.render() for item in self.items]}
675 def remove_opaque(self) -> JsonSchemaType | None:
676 items = [
677 clear for item in self.items if (clear := item.remove_opaque()) is not None
678 ]
679 if items:
680 return AllOf(items)
681 else:
682 return None
684 def pprint(self) -> str:
685 return " & ".join(
686 f"{item}" if item.precedence >= self.precedence else f"({item})"
687 for item in self.items
688 )
691@dataclass(frozen=True, slots=True, init=False)
692class AnyOf(JsonSchemaType):
693 """
694 A union of possible values, equivalent to ``anyOf`` schema.
696 """
698 precedence = 2
700 items: _t.Sequence[JsonSchemaType]
701 """
702 Inner items.
704 """
706 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
707 flatten: list[JsonSchemaType] = []
708 for type in items:
709 if isinstance(type, Never):
710 pass
711 elif isinstance(type, AnyOf):
712 flatten.extend(type.items)
713 else:
714 flatten.append(type)
715 if not flatten:
716 return Never()
717 elif len(flatten) == 1:
718 return flatten[0]
719 self = object.__new__(cls)
720 object.__setattr__(self, "items", flatten)
721 return self
723 def render(self) -> dict[str, JsonValue]:
724 return {"anyOf": [item.render() for item in self.items]}
726 def remove_opaque(self) -> JsonSchemaType | None:
727 items = [
728 clear for item in self.items if (clear := item.remove_opaque()) is not None
729 ]
730 if items:
731 return AnyOf(items)
732 else:
733 return None
735 def pprint(self) -> str:
736 return " | ".join(
737 f"{item}" if item.precedence >= self.precedence else f"({item})"
738 for item in self.items
739 )
742@dataclass(frozen=True, slots=True)
743class Enum(JsonSchemaType):
744 """
745 An enum of primitive constants.
747 """
749 precedence = 2
751 constants: _t.Sequence[str | int | float | bool | None]
752 """
753 Enum elements.
755 """
757 descriptions: _t.Sequence[str | None] | None = None
758 """
759 Descriptions for enum items. If given, list of descriptions should have the same
760 length as the list of constants.
762 """
764 def render(self) -> dict[str, JsonValue]:
765 if self.descriptions is None:
766 return {"enum": list(self.constants)}
767 else:
768 assert len(self.descriptions) == len(self.constants)
769 return {
770 "oneOf": [
771 {
772 "const": const,
773 **({"description": description} if description else {}),
774 }
775 for const, description in zip(self.constants, self.descriptions)
776 ]
777 }
779 def pprint(self) -> str:
780 return " | ".join(f"{json.dumps(item)}" for item in self.constants)
783@dataclass(frozen=True, slots=True)
784class Object(JsonSchemaType):
785 """
786 An object, usually represents a :class:`~yuio.config.Config`.
788 """
790 properties: dict[str, JsonSchemaType]
791 """
792 Object keys and their types.
794 """
796 def render(self) -> dict[str, JsonValue]:
797 return {
798 "type": "object",
799 "properties": {
800 name: type.render() for name, type in self.properties.items()
801 },
802 "additionalProperties": False,
803 }
805 def remove_opaque(self) -> JsonSchemaType | None:
806 properties = {
807 name: clear
808 for name, item in self.properties.items()
809 if (clear := item.remove_opaque()) is not None
810 }
811 if properties:
812 return Object(properties)
813 else:
814 return None
816 def pprint(self) -> str:
817 items = ", ".join(
818 f"{name}: {item.pprint()}" for name, item in self.properties.items()
819 )
820 return f"{{{items}}}"
823@dataclass(frozen=True, slots=True)
824class Opaque(JsonSchemaType):
825 """
826 Can contain arbitrary schema, for cases when these classes
827 can't represent required constraints.
829 """
831 schema: dict[str, JsonValue]
832 """
833 Arbitrary schema. This should be a dictionary so that :class:`Meta` can add
834 additional data to it.
836 """
838 def render(self) -> dict[str, JsonValue]:
839 return self.schema
841 def remove_opaque(self) -> JsonSchemaType | None:
842 return None
844 def pprint(self) -> str:
845 return "..."
848@dataclass(frozen=True, slots=True)
849class Meta(JsonSchemaType):
850 """
851 Adds title, description and defaults to the wrapped schema.
853 """
855 item: JsonSchemaType
856 """
857 Inner type.
859 """
861 title: str | None = None
862 """
863 Title for the wrapped item.
865 """
867 description: str | None = None
868 """
869 Description for the wrapped item.
871 """
873 default: JsonValue | yuio.Missing = yuio.MISSING
874 """
875 Default value for the wrapped item.
877 """
879 @property
880 def precedence(self): # pyright: ignore[reportIncompatibleVariableOverride]
881 return 3 if self.title else self.item.precedence
883 def render(self) -> dict[str, JsonValue]:
884 schema = self.item.render()
885 if self.title is not None:
886 schema["title"] = self.title
887 if self.description is not None:
888 schema["description"] = _dedent(self.description)
889 if self.default is not yuio.MISSING:
890 schema["default"] = self.default
891 return schema
893 def remove_opaque(self) -> JsonSchemaType | None:
894 item = self.item.remove_opaque()
895 if item is not None:
896 return Meta(item, self.title, self.description, self.default)
897 else:
898 return None
900 def pprint(self) -> str:
901 return self.title or self.item.pprint()