Coverage for yuio / json_schema.py: 99%
318 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +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 isinstance(key, (Ref, Meta)):
436 key = key.item
438 if isinstance(key, String):
439 schema["type"] = [schema["type"], "object"]
440 schema["propertyNames"] = self.key.render()
441 schema["additionalProperties"] = self.value.render()
442 elif isinstance(key, Enum) and all(
443 isinstance(constant, str) for constant in key.constants
444 ):
445 schema["type"] = [schema["type"], "object"]
446 schema["properties"] = properties = {}
447 value_schema = self.value.render()
448 for constant in key.constants:
449 properties[constant] = value_schema
450 schema["additionalProperties"] = False
451 return schema
453 def remove_opaque(self) -> JsonSchemaType | None:
454 key = self.key.remove_opaque()
455 value = self.value.remove_opaque()
456 if key is not None and value is not None:
457 return Dict(key, value)
458 else:
459 return None
461 def pprint(self) -> str:
462 return f"{{[{self.key.pprint()}]: {self.value.pprint()}}}"
465def _is_string_like(schema: JsonSchemaType):
466 return (
467 isinstance(schema, String)
468 or isinstance(schema, Enum)
469 and any(isinstance(constant, str) for constant in schema.constants)
470 )
473@dataclass(frozen=True, slots=True)
474class Null(JsonSchemaType):
475 """
476 A ``null`` value.
478 """
480 def render(self) -> dict[str, JsonValue]:
481 return {"type": "null"}
483 def pprint(self) -> str:
484 return "null"
487@dataclass(frozen=True, slots=True)
488class Boolean(JsonSchemaType):
489 """
490 A boolean value.
492 """
494 def render(self) -> dict[str, JsonValue]:
495 return {"type": "boolean"}
497 def pprint(self) -> str:
498 return "boolean"
501@dataclass(frozen=True, slots=True)
502class Number(JsonSchemaType):
503 """
504 A numeric value.
506 """
508 def render(self) -> dict[str, JsonValue]:
509 return {"type": "number"}
511 def pprint(self) -> str:
512 return "number"
515@dataclass(frozen=True, slots=True)
516class Integer(Number):
517 """
518 An integer value.
520 """
522 def render(self) -> dict[str, JsonValue]:
523 return {"type": "integer"}
525 def pprint(self) -> str:
526 return "integer"
529@dataclass(frozen=True, slots=True)
530class String(JsonSchemaType):
531 """
532 A string value, possibly with pattern.
534 """
536 pattern: str | None = None
537 """
538 Regular expression for checking string elements.
540 """
542 def render(self) -> dict[str, JsonValue]:
543 schema: dict[str, JsonValue] = {"type": "string"}
544 if self.pattern is not None:
545 schema["pattern"] = self.pattern
546 return schema
548 def pprint(self) -> str:
549 return "string"
552@dataclass(frozen=True, slots=True)
553class Any(JsonSchemaType):
554 """
555 A value that always type checks, equivalent to schema ``true``.
557 """
559 def render(self) -> dict[str, JsonValue]:
560 return {}
562 def pprint(self) -> str:
563 return "any"
566@dataclass(frozen=True, slots=True)
567class Never(JsonSchemaType):
568 """
569 A value that never type checks, equivalent to schema ``false``.
571 """
573 def render(self) -> dict[str, JsonValue]:
574 return {"allOf": [False]}
576 def pprint(self) -> str:
577 return "never"
580@dataclass(frozen=True, slots=True, init=False)
581class OneOf(JsonSchemaType):
582 """
583 A union of possible values, equivalent to ``oneOf`` schema.
585 """
587 precedence = 2
589 items: _t.Sequence[JsonSchemaType]
590 """
591 Inner items.
593 """
595 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
596 flatten: list[JsonSchemaType] = []
597 for type in items:
598 if isinstance(type, Never):
599 pass
600 elif isinstance(type, OneOf):
601 flatten.extend(type.items)
602 else:
603 flatten.append(type)
604 if not flatten:
605 return Never()
606 elif len(flatten) == 1:
607 return flatten[0]
608 self = object.__new__(cls)
609 object.__setattr__(self, "items", flatten)
610 return self
612 def render(self) -> dict[str, JsonValue]:
613 return {"oneOf": [item.render() for item in self.items]}
615 def remove_opaque(self) -> JsonSchemaType | None:
616 items = [
617 clear for item in self.items if (clear := item.remove_opaque()) is not None
618 ]
619 if items:
620 return OneOf(items)
621 else:
622 return None
624 def pprint(self) -> str:
625 return " | ".join(
626 f"{item}" if item.precedence >= self.precedence else f"({item})"
627 for item in self.items
628 )
631@dataclass(frozen=True, slots=True, init=False)
632class AllOf(JsonSchemaType):
633 """
634 An intersection of possible values, equivalent to ``allOf`` schema.
636 """
638 precedence = 1
640 items: _t.Sequence[JsonSchemaType]
641 """
642 Inner items.
644 """
646 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
647 flatten: list[JsonSchemaType] = []
648 for type in items:
649 if isinstance(type, Never):
650 pass
651 elif isinstance(type, AllOf):
652 flatten.extend(type.items)
653 else:
654 flatten.append(type)
655 if not flatten:
656 return Never()
657 elif len(flatten) == 1:
658 return flatten[0]
659 self = object.__new__(cls)
660 object.__setattr__(self, "items", flatten)
661 return self
663 def render(self) -> dict[str, JsonValue]:
664 return {"allOf": [item.render() for item in self.items]}
666 def remove_opaque(self) -> JsonSchemaType | None:
667 items = [
668 clear for item in self.items if (clear := item.remove_opaque()) is not None
669 ]
670 if items:
671 return AllOf(items)
672 else:
673 return None
675 def pprint(self) -> str:
676 return " & ".join(
677 f"{item}" if item.precedence >= self.precedence else f"({item})"
678 for item in self.items
679 )
682@dataclass(frozen=True, slots=True, init=False)
683class AnyOf(JsonSchemaType):
684 """
685 A union of possible values, equivalent to ``anyOf`` schema.
687 """
689 precedence = 2
691 items: _t.Sequence[JsonSchemaType]
692 """
693 Inner items.
695 """
697 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
698 flatten: list[JsonSchemaType] = []
699 for type in items:
700 if isinstance(type, Never):
701 pass
702 elif isinstance(type, AnyOf):
703 flatten.extend(type.items)
704 else:
705 flatten.append(type)
706 if not flatten:
707 return Never()
708 elif len(flatten) == 1:
709 return flatten[0]
710 self = object.__new__(cls)
711 object.__setattr__(self, "items", flatten)
712 return self
714 def render(self) -> dict[str, JsonValue]:
715 return {"anyOf": [item.render() for item in self.items]}
717 def remove_opaque(self) -> JsonSchemaType | None:
718 items = [
719 clear for item in self.items if (clear := item.remove_opaque()) is not None
720 ]
721 if items:
722 return AnyOf(items)
723 else:
724 return None
726 def pprint(self) -> str:
727 return " | ".join(
728 f"{item}" if item.precedence >= self.precedence else f"({item})"
729 for item in self.items
730 )
733@dataclass(frozen=True, slots=True)
734class Enum(JsonSchemaType):
735 """
736 An enum of primitive constants.
738 """
740 precedence = 2
742 constants: _t.Sequence[str | int | float | bool | None]
743 """
744 Enum elements.
746 """
748 descriptions: _t.Sequence[str | None] | None = None
749 """
750 Descriptions for enum items. If given, list of descriptions should have the same
751 length as the list of constants.
753 """
755 def render(self) -> dict[str, JsonValue]:
756 if self.descriptions is None:
757 return {"enum": list(self.constants)}
758 else:
759 assert len(self.descriptions) == len(self.constants)
760 return {
761 "oneOf": [
762 {
763 "const": const,
764 **({"description": description} if description else {}),
765 }
766 for const, description in zip(self.constants, self.descriptions)
767 ]
768 }
770 def pprint(self) -> str:
771 return " | ".join(f"{json.dumps(item)}" for item in self.constants)
774@dataclass(frozen=True, slots=True)
775class Object(JsonSchemaType):
776 """
777 An object, usually represents a :class:`~yuio.config.Config`.
779 """
781 properties: dict[str, JsonSchemaType]
782 """
783 Object keys and their types.
785 """
787 def render(self) -> dict[str, JsonValue]:
788 return {
789 "type": "object",
790 "properties": {
791 name: type.render() for name, type in self.properties.items()
792 },
793 "additionalProperties": False,
794 }
796 def remove_opaque(self) -> JsonSchemaType | None:
797 properties = {
798 name: clear
799 for name, item in self.properties.items()
800 if (clear := item.remove_opaque()) is not None
801 }
802 if properties:
803 return Object(properties)
804 else:
805 return None
807 def pprint(self) -> str:
808 items = ", ".join(
809 f"{name}: {item.pprint()}" for name, item in self.properties.items()
810 )
811 return f"{{{items}}}"
814@dataclass(frozen=True, slots=True)
815class Opaque(JsonSchemaType):
816 """
817 Can contain arbitrary schema, for cases when these classes
818 can't represent required constraints.
820 """
822 schema: dict[str, JsonValue]
823 """
824 Arbitrary schema. This should be a dictionary so that :class:`Meta` can add
825 additional data to it.
827 """
829 def render(self) -> dict[str, JsonValue]:
830 return self.schema
832 def remove_opaque(self) -> JsonSchemaType | None:
833 return None
835 def pprint(self) -> str:
836 return "..."
839@dataclass(frozen=True, slots=True)
840class Meta(JsonSchemaType):
841 """
842 Adds title, description and defaults to the wrapped schema.
844 """
846 item: JsonSchemaType
847 """
848 Inner type.
850 """
852 title: str | None = None
853 """
854 Title for the wrapped item.
856 """
858 description: str | None = None
859 """
860 Description for the wrapped item.
862 """
864 default: JsonValue | yuio.Missing = yuio.MISSING
865 """
866 Default value for the wrapped item.
868 """
870 @property
871 def precedence(self): # pyright: ignore[reportIncompatibleVariableOverride]
872 return 3 if self.title else self.item.precedence
874 def render(self) -> dict[str, JsonValue]:
875 schema = self.item.render()
876 if self.title is not None:
877 schema["title"] = self.title
878 if self.description is not None:
879 schema["description"] = _dedent(self.description)
880 if self.default is not yuio.MISSING:
881 schema["default"] = self.default
882 return schema
884 def remove_opaque(self) -> JsonSchemaType | None:
885 item = self.item.remove_opaque()
886 if item is not None:
887 return Meta(item, self.title, self.description, self.default)
888 else:
889 return None
891 def pprint(self) -> str:
892 return self.title or self.item.pprint()