Coverage for yuio / json_schema.py: 100%
303 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +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 return Ref(f"#/$defs/{self._types[key][0]}", self._types[key][0])
221 def get_type(self, ref: str) -> JsonSchemaType | None:
222 """
223 Get saved type by ``$ref``.
225 :param ref:
226 contents of the ``$ref`` anchor.
227 :returns:
228 schema that was earlier passed to :meth:`~JsonSchemaContext.add_type`.
230 """
232 return self._defs.get(ref)
234 def render(
235 self,
236 root: JsonSchemaType,
237 /,
238 *,
239 id: str | None = None,
240 ) -> JsonValue:
241 """
242 Convert schema to a value suitable for JSON serialization.
244 :returns:
245 complete JSON representation of a schema.
247 """
249 schema: dict[str, JsonValue] = {
250 "$schema": "https://json-schema.org/draft-07/schema",
251 }
252 if id:
253 schema["$id"] = id
254 schema.update(root.render())
255 schema["$defs"] = {name: ref.render() for name, ref in self._defs.items()}
256 return schema
259@dataclass(frozen=True, slots=True, init=False)
260class JsonSchemaType(abc.ABC):
261 """
262 Base class for JSON schema representation.
264 """
266 precedence: _ClassVar[int] = 3
267 """
268 Precedence, used for pretty-printing types.
270 """
272 @abc.abstractmethod
273 def render(self) -> dict[str, JsonValue]:
274 """
275 Serialize type as JSON.
277 """
279 raise NotImplementedError()
281 def remove_opaque(self) -> JsonSchemaType | None:
282 """
283 Return a new type with all instances of :class:`Opaque` removed from it.
285 This is usually used before pretty-printing type for documentation.
287 """
289 return self
291 @abc.abstractmethod
292 def pprint(self) -> str:
293 """
294 Pretty-print this type using TypeScript syntax.
296 """
298 raise NotImplementedError()
300 def __str__(self) -> str:
301 return self.pprint()
304@dataclass(frozen=True, slots=True)
305class Ref(JsonSchemaType):
306 """
307 A reference to a sub-schema.
309 Use :meth:`JsonSchemaContext.add_type` to create these.
311 """
313 ref: str
314 """
315 Referenced type.
317 """
319 name: str | None = None
320 """
321 Name of the referenced type, used for debug.
323 """
325 def render(self) -> dict[str, JsonValue]:
326 return {"$ref": self.ref}
328 def pprint(self) -> str:
329 return self.name or self.ref.removeprefix("#/$defs/")
332@dataclass(frozen=True, slots=True)
333class Array(JsonSchemaType):
334 """
335 An array or a set of values.
337 """
339 item: JsonSchemaType
340 """
341 Type of array elements.
343 """
345 unique_items: bool = False
346 """
347 Whether all array items should be unique.
349 """
351 def render(self) -> dict[str, JsonValue]:
352 schema: dict[str, JsonValue] = {"type": "array", "items": self.item.render()}
353 if self.unique_items:
354 schema["uniqueItems"] = True
355 return schema
357 def remove_opaque(self) -> JsonSchemaType | None:
358 item = self.item.remove_opaque()
359 return Array(item, self.unique_items) if item is not None else None
361 def pprint(self) -> str:
362 if self.item.precedence >= self.precedence:
363 return f"{self.item.pprint()}[]"
364 else:
365 return f"({self.item.pprint()})[]"
368@dataclass(frozen=True, slots=True)
369class Tuple(JsonSchemaType):
370 """
371 A tuple.
373 """
375 items: _t.Sequence[JsonSchemaType]
376 """
377 Types of tuple elements.
379 """
381 def render(self) -> dict[str, JsonValue]:
382 return {
383 "type": "array",
384 "items": [item.render() for item in self.items],
385 "minItems": len(self.items),
386 "maxItems": len(self.items),
387 "additionalItems": False,
388 }
390 def remove_opaque(self) -> JsonSchemaType | None:
391 return Tuple(
392 [
393 clear
394 for item in self.items
395 if (clear := item.remove_opaque()) is not None
396 ]
397 )
399 def pprint(self) -> str:
400 return f"[{', '.join(item.pprint() for item in self.items)}]"
403@dataclass(frozen=True, slots=True)
404class Dict(JsonSchemaType):
405 """
406 A dict. If key is string, this is represented as an object; if key is not a string,
407 this is represented as an array of pairs.
409 """
411 key: JsonSchemaType
412 """
413 Type of dict keys.
415 """
417 value: JsonSchemaType
418 """
419 Type of dict values.
421 """
423 def render(self) -> dict[str, JsonValue]:
424 schema: dict[str, JsonValue] = Array(Tuple([self.key, self.value])).render()
425 if isinstance(self.key, String):
426 schema["type"] = [schema["type"], "object"]
427 schema["propertyNames"] = self.key.render()
428 schema["additionalProperties"] = self.value.render()
429 return schema
431 def remove_opaque(self) -> JsonSchemaType | None:
432 key = self.key.remove_opaque()
433 value = self.value.remove_opaque()
434 if key is not None and value is not None:
435 return Dict(key, value)
436 else:
437 return None
439 def pprint(self) -> str:
440 return f"{{[{self.key.pprint()}]: {self.value.pprint()}}}"
443@dataclass(frozen=True, slots=True)
444class Null(JsonSchemaType):
445 """
446 A ``null`` value.
448 """
450 def render(self) -> dict[str, JsonValue]:
451 return {"type": "null"}
453 def pprint(self) -> str:
454 return "null"
457@dataclass(frozen=True, slots=True)
458class Boolean(JsonSchemaType):
459 """
460 A boolean value.
462 """
464 def render(self) -> dict[str, JsonValue]:
465 return {"type": "boolean"}
467 def pprint(self) -> str:
468 return "boolean"
471@dataclass(frozen=True, slots=True)
472class Number(JsonSchemaType):
473 """
474 A numeric value.
476 """
478 def render(self) -> dict[str, JsonValue]:
479 return {"type": "number"}
481 def pprint(self) -> str:
482 return "number"
485@dataclass(frozen=True, slots=True)
486class Integer(Number):
487 """
488 An integer value.
490 """
492 def render(self) -> dict[str, JsonValue]:
493 return {"type": "integer"}
495 def pprint(self) -> str:
496 return "integer"
499@dataclass(frozen=True, slots=True)
500class String(JsonSchemaType):
501 """
502 A string value, possibly with pattern.
504 """
506 pattern: str | None = None
507 """
508 Regular expression for checking string elements.
510 """
512 def render(self) -> dict[str, JsonValue]:
513 schema: dict[str, JsonValue] = {"type": "string"}
514 if self.pattern is not None:
515 schema["pattern"] = self.pattern
516 return schema
518 def pprint(self) -> str:
519 return "string"
522@dataclass(frozen=True, slots=True)
523class Any(JsonSchemaType):
524 """
525 A value that always type checks, equivalent to schema ``true``.
527 """
529 def render(self) -> dict[str, JsonValue]:
530 return {}
532 def pprint(self) -> str:
533 return "any"
536@dataclass(frozen=True, slots=True)
537class Never(JsonSchemaType):
538 """
539 A value that never type checks, equivalent to schema ``false``.
541 """
543 def render(self) -> dict[str, JsonValue]:
544 return {"allOf": [False]}
546 def pprint(self) -> str:
547 return "never"
550@dataclass(frozen=True, slots=True, init=False)
551class OneOf(JsonSchemaType):
552 """
553 A union of possible values, equivalent to ``oneOf`` schema.
555 """
557 precedence = 2
559 items: _t.Sequence[JsonSchemaType]
560 """
561 Inner items.
563 """
565 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
566 flatten: list[JsonSchemaType] = []
567 for type in items:
568 if isinstance(type, Never):
569 pass
570 elif isinstance(type, OneOf):
571 flatten.extend(type.items)
572 else:
573 flatten.append(type)
574 if not flatten:
575 return Never()
576 elif len(flatten) == 1:
577 return flatten[0]
578 self = object.__new__(cls)
579 object.__setattr__(self, "items", flatten)
580 return self
582 def render(self) -> dict[str, JsonValue]:
583 return {"oneOf": [item.render() for item in self.items]}
585 def remove_opaque(self) -> JsonSchemaType | None:
586 items = [
587 clear for item in self.items if (clear := item.remove_opaque()) is not None
588 ]
589 if items:
590 return OneOf(items)
591 else:
592 return None
594 def pprint(self) -> str:
595 return " | ".join(
596 f"{item}" if item.precedence >= self.precedence else f"({item})"
597 for item in self.items
598 )
601@dataclass(frozen=True, slots=True, init=False)
602class AllOf(JsonSchemaType):
603 """
604 An intersection of possible values, equivalent to ``allOf`` schema.
606 """
608 precedence = 1
610 items: _t.Sequence[JsonSchemaType]
611 """
612 Inner items.
614 """
616 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
617 flatten: list[JsonSchemaType] = []
618 for type in items:
619 if isinstance(type, Never):
620 pass
621 elif isinstance(type, AllOf):
622 flatten.extend(type.items)
623 else:
624 flatten.append(type)
625 if not flatten:
626 return Never()
627 elif len(flatten) == 1:
628 return flatten[0]
629 self = object.__new__(cls)
630 object.__setattr__(self, "items", flatten)
631 return self
633 def render(self) -> dict[str, JsonValue]:
634 return {"allOf": [item.render() for item in self.items]}
636 def remove_opaque(self) -> JsonSchemaType | None:
637 items = [
638 clear for item in self.items if (clear := item.remove_opaque()) is not None
639 ]
640 if items:
641 return AllOf(items)
642 else:
643 return None
645 def pprint(self) -> str:
646 return " & ".join(
647 f"{item}" if item.precedence >= self.precedence else f"({item})"
648 for item in self.items
649 )
652@dataclass(frozen=True, slots=True, init=False)
653class AnyOf(JsonSchemaType):
654 """
655 A union of possible values, equivalent to ``anyOf`` schema.
657 """
659 precedence = 2
661 items: _t.Sequence[JsonSchemaType]
662 """
663 Inner items.
665 """
667 def __new__(cls, items: _t.Sequence[JsonSchemaType]) -> JsonSchemaType:
668 flatten: list[JsonSchemaType] = []
669 for type in items:
670 if isinstance(type, Never):
671 pass
672 elif isinstance(type, AnyOf):
673 flatten.extend(type.items)
674 else:
675 flatten.append(type)
676 if not flatten:
677 return Never()
678 elif len(flatten) == 1:
679 return flatten[0]
680 self = object.__new__(cls)
681 object.__setattr__(self, "items", flatten)
682 return self
684 def render(self) -> dict[str, JsonValue]:
685 return {"anyOf": [item.render() for item in self.items]}
687 def remove_opaque(self) -> JsonSchemaType | None:
688 items = [
689 clear for item in self.items if (clear := item.remove_opaque()) is not None
690 ]
691 if items:
692 return AnyOf(items)
693 else:
694 return None
696 def pprint(self) -> str:
697 return " | ".join(
698 f"{item}" if item.precedence >= self.precedence else f"({item})"
699 for item in self.items
700 )
703@dataclass(frozen=True, slots=True)
704class Enum(JsonSchemaType):
705 """
706 An enum of primitive constants.
708 """
710 precedence = 2
712 constants: _t.Sequence[str | int | float | bool | None]
713 """
714 Enum elements.
716 """
718 descriptions: _t.Sequence[str | None] | None = None
719 """
720 Descriptions for enum items. If given, list of descriptions should have the same
721 length as the list of constants.
723 """
725 def render(self) -> dict[str, JsonValue]:
726 if self.descriptions is None:
727 return {"enum": list(self.constants)}
728 else:
729 assert len(self.descriptions) == len(self.constants)
730 return {
731 "oneOf": [
732 {
733 "const": const,
734 **({"description": description} if description else {}),
735 }
736 for const, description in zip(self.constants, self.descriptions)
737 ]
738 }
740 def pprint(self) -> str:
741 return " | ".join(f"{json.dumps(item)}" for item in self.constants)
744@dataclass(frozen=True, slots=True)
745class Object(JsonSchemaType):
746 """
747 An object, usually represents a :class:`~yuio.config.Config`.
749 """
751 properties: dict[str, JsonSchemaType]
752 """
753 Object keys and their types.
755 """
757 def render(self) -> dict[str, JsonValue]:
758 return {
759 "type": "object",
760 "properties": {
761 name: type.render() for name, type in self.properties.items()
762 },
763 "additionalProperties": False,
764 }
766 def remove_opaque(self) -> JsonSchemaType | None:
767 properties = {
768 name: clear
769 for name, item in self.properties.items()
770 if (clear := item.remove_opaque()) is not None
771 }
772 if properties:
773 return Object(properties)
774 else:
775 return None
777 def pprint(self) -> str:
778 items = ", ".join(
779 f"{name}: {item.pprint()}" for name, item in self.properties.items()
780 )
781 return f"{{{items}}}"
784@dataclass(frozen=True, slots=True)
785class Opaque(JsonSchemaType):
786 """
787 Can contain arbitrary schema, for cases when these classes
788 can't represent required constraints.
790 """
792 schema: dict[str, JsonValue]
793 """
794 Arbitrary schema. This should be a dictionary so that :class:`Meta` can add
795 additional data to it.
797 """
799 def render(self) -> dict[str, JsonValue]:
800 return self.schema
802 def remove_opaque(self) -> JsonSchemaType | None:
803 return None
805 def pprint(self) -> str:
806 return "..."
809@dataclass(frozen=True, slots=True)
810class Meta(JsonSchemaType):
811 """
812 Adds title, description and defaults to the wrapped schema.
814 """
816 item: JsonSchemaType
817 """
818 Inner type.
820 """
822 title: str | None = None
823 """
824 Title for the wrapped item.
826 """
828 description: str | None = None
829 """
830 Description for the wrapped item.
832 """
834 default: JsonValue | yuio.Missing = yuio.MISSING
835 """
836 Default value for the wrapped item.
838 """
840 @property
841 def precedence(self): # pyright: ignore[reportIncompatibleVariableOverride]
842 return 3 if self.title else self.item.precedence
844 def render(self) -> dict[str, JsonValue]:
845 schema = self.item.render()
846 if self.title is not None:
847 schema["title"] = self.title
848 if self.description is not None:
849 schema["description"] = _dedent(self.description)
850 if self.default is not yuio.MISSING:
851 schema["default"] = self.default
852 return schema
854 def remove_opaque(self) -> JsonSchemaType | None:
855 item = self.item.remove_opaque()
856 if item is not None:
857 return Meta(item, self.title, self.description, self.default)
858 else:
859 return None
861 def pprint(self) -> str:
862 return self.title or self.item.pprint()