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

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 

7 

8""" 

9A simple JSON schema representation to describe configs and types. 

10 

11This module primarily used with 

12:meth:`Parser.to_json_schema <yuio.parse.Parser.to_json_schema>` 

13to generate config schemas used in IDEs. 

14 

15.. class:: JsonValue 

16 

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. 

19 

20 

21JSON types 

22---------- 

23 

24.. autoclass:: JsonSchemaType 

25 :members: 

26 

27.. autoclass:: Ref 

28 :members: 

29 

30.. autoclass:: Array 

31 :members: 

32 

33.. autoclass:: Tuple 

34 :members: 

35 

36.. autoclass:: Dict 

37 :members: 

38 

39.. autoclass:: Null 

40 :members: 

41 

42.. autoclass:: Boolean 

43 :members: 

44 

45.. autoclass:: Number 

46 :members: 

47 

48.. autoclass:: Integer 

49 :members: 

50 

51.. autoclass:: String 

52 :members: 

53 

54.. autoclass:: Any 

55 :members: 

56 

57.. autoclass:: Never 

58 :members: 

59 

60.. autoclass:: OneOf 

61 :members: 

62 

63.. autoclass:: AllOf 

64 :members: 

65 

66.. autoclass:: AnyOf 

67 :members: 

68 

69.. autoclass:: Enum 

70 :members: 

71 

72.. autoclass:: Object 

73 :members: 

74 

75.. autoclass:: Opaque 

76 :members: 

77 

78.. autoclass:: Meta 

79 :members: 

80 

81 

82Building a schema 

83----------------- 

84 

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>`. 

88 

89To convert it to JSON value, use :meth:`JsonSchemaContext.render`, and possibly 

90wrap the schema into :class:`Meta`: 

91 

92.. invisible-code-block: python 

93 

94 from yuio.config import Config 

95 import yuio.json_schema 

96 import json 

97 class AppConfig(Config): ... 

98 

99.. code-block:: python 

100 

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) 

107 

108.. autoclass:: JsonSchemaContext 

109 :members: 

110 

111""" 

112 

113from __future__ import annotations 

114 

115import abc 

116import json 

117import os 

118from dataclasses import dataclass 

119 

120import yuio 

121from yuio.util import dedent as _dedent 

122 

123from typing import TYPE_CHECKING 

124from typing import ClassVar as _ClassVar 

125 

126if TYPE_CHECKING: 

127 import typing_extensions as _t 

128else: 

129 from yuio import _typing as _t 

130 

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] 

154 

155T = _t.TypeVar("T") 

156 

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 ) 

166 

167else: # pragma: no cover 

168 

169 def _JsonValue(arg: T) -> T: 

170 """ 

171 JSON value marker, used to detect JSON type hints at runtime. 

172 

173 """ 

174 

175 return arg 

176 

177 JsonValue: _t.TypeAlias = _JsonValue # type: ignore 

178 

179 

180class JsonSchemaContext: 

181 """ 

182 Context for building schema. 

183 

184 """ 

185 

186 def __init__(self): 

187 self._types: dict[type, tuple[str, JsonSchemaType]] = {} 

188 self._defs: dict[str, JsonSchemaType] = {} 

189 

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. 

195 

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. 

208 

209 """ 

210 

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]) 

222 

223 def get_type(self, ref: str) -> JsonSchemaType | None: 

224 """ 

225 Get saved type by ``$ref``. 

226 

227 :param ref: 

228 contents of the ``$ref`` anchor. 

229 :returns: 

230 schema that was earlier passed to :meth:`~JsonSchemaContext.add_type`. 

231 

232 """ 

233 

234 return self._defs.get(ref) 

235 

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. 

245 

246 :returns: 

247 complete JSON representation of a schema. 

248 

249 """ 

250 

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 

259 

260 

261@dataclass(frozen=True, slots=True, init=False) 

262class JsonSchemaType(abc.ABC): 

263 """ 

264 Base class for JSON schema representation. 

265 

266 """ 

267 

268 precedence: _ClassVar[int] = 3 

269 """ 

270 Precedence, used for pretty-printing types. 

271 

272 """ 

273 

274 @abc.abstractmethod 

275 def render(self) -> dict[str, JsonValue]: 

276 """ 

277 Serialize type as JSON. 

278 

279 """ 

280 

281 raise NotImplementedError() 

282 

283 def remove_opaque(self) -> JsonSchemaType | None: 

284 """ 

285 Return a new type with all instances of :class:`Opaque` removed from it. 

286 

287 This is usually used before pretty-printing type for documentation. 

288 

289 """ 

290 

291 return self 

292 

293 @abc.abstractmethod 

294 def pprint(self) -> str: 

295 """ 

296 Pretty-print this type using TypeScript syntax. 

297 

298 """ 

299 

300 raise NotImplementedError() 

301 

302 def __str__(self) -> str: 

303 return self.pprint() 

304 

305 

306@dataclass(frozen=True, slots=True) 

307class Ref(JsonSchemaType): 

308 """ 

309 A reference to a sub-schema. 

310 

311 Use :meth:`JsonSchemaContext.add_type` to create these. 

312 

313 """ 

314 

315 ref: str 

316 """ 

317 Referenced type. 

318 

319 """ 

320 

321 item: JsonSchemaType 

322 """ 

323 Access to the wrapped type. 

324 

325 """ 

326 

327 name: str | None = None 

328 """ 

329 Name of the referenced type, used for debug. 

330 

331 """ 

332 

333 def render(self) -> dict[str, JsonValue]: 

334 return {"$ref": self.ref} 

335 

336 def pprint(self) -> str: 

337 return self.name or self.ref.removeprefix("#/$defs/") 

338 

339 

340@dataclass(frozen=True, slots=True) 

341class Array(JsonSchemaType): 

342 """ 

343 An array or a set of values. 

344 

345 """ 

346 

347 item: JsonSchemaType 

348 """ 

349 Type of array elements. 

350 

351 """ 

352 

353 unique_items: bool = False 

354 """ 

355 Whether all array items should be unique. 

356 

357 """ 

358 

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 

364 

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 

368 

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()})[]" 

374 

375 

376@dataclass(frozen=True, slots=True) 

377class Tuple(JsonSchemaType): 

378 """ 

379 A tuple. 

380 

381 """ 

382 

383 items: _t.Sequence[JsonSchemaType] 

384 """ 

385 Types of tuple elements. 

386 

387 """ 

388 

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 } 

397 

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 ) 

406 

407 def pprint(self) -> str: 

408 return f"[{', '.join(item.pprint() for item in self.items)}]" 

409 

410 

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. 

416 

417 """ 

418 

419 key: JsonSchemaType 

420 """ 

421 Type of dict keys. 

422 

423 """ 

424 

425 value: JsonSchemaType 

426 """ 

427 Type of dict values. 

428 

429 """ 

430 

431 def render(self) -> dict[str, JsonValue]: 

432 schema: dict[str, JsonValue] = Array(Tuple([self.key, self.value])).render() 

433 

434 key = self.key 

435 while isinstance(key, (Ref, Meta)): 

436 key = key.item 

437 

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 

452 

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 

460 

461 def pprint(self) -> str: 

462 return f"{{[{self.key.pprint()}]: {self.value.pprint()}}}" 

463 

464 

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 ) 

471 

472 

473@dataclass(frozen=True, slots=True) 

474class Null(JsonSchemaType): 

475 """ 

476 A ``null`` value. 

477 

478 """ 

479 

480 def render(self) -> dict[str, JsonValue]: 

481 return {"type": "null"} 

482 

483 def pprint(self) -> str: 

484 return "null" 

485 

486 

487@dataclass(frozen=True, slots=True) 

488class Boolean(JsonSchemaType): 

489 """ 

490 A boolean value. 

491 

492 """ 

493 

494 def render(self) -> dict[str, JsonValue]: 

495 return {"type": "boolean"} 

496 

497 def pprint(self) -> str: 

498 return "boolean" 

499 

500 

501@dataclass(frozen=True, slots=True) 

502class Number(JsonSchemaType): 

503 """ 

504 A numeric value. 

505 

506 """ 

507 

508 def render(self) -> dict[str, JsonValue]: 

509 return {"type": "number"} 

510 

511 def pprint(self) -> str: 

512 return "number" 

513 

514 

515@dataclass(frozen=True, slots=True) 

516class Integer(Number): 

517 """ 

518 An integer value. 

519 

520 """ 

521 

522 def render(self) -> dict[str, JsonValue]: 

523 return {"type": "integer"} 

524 

525 def pprint(self) -> str: 

526 return "integer" 

527 

528 

529@dataclass(frozen=True, slots=True) 

530class String(JsonSchemaType): 

531 """ 

532 A string value, possibly with pattern. 

533 

534 """ 

535 

536 pattern: str | None = None 

537 """ 

538 Regular expression for checking string elements. 

539 

540 """ 

541 

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 

547 

548 def pprint(self) -> str: 

549 return "string" 

550 

551 

552@dataclass(frozen=True, slots=True) 

553class Any(JsonSchemaType): 

554 """ 

555 A value that always type checks, equivalent to schema ``true``. 

556 

557 """ 

558 

559 def render(self) -> dict[str, JsonValue]: 

560 return {} 

561 

562 def pprint(self) -> str: 

563 return "any" 

564 

565 

566@dataclass(frozen=True, slots=True) 

567class Never(JsonSchemaType): 

568 """ 

569 A value that never type checks, equivalent to schema ``false``. 

570 

571 """ 

572 

573 def render(self) -> dict[str, JsonValue]: 

574 return {"allOf": [False]} 

575 

576 def pprint(self) -> str: 

577 return "never" 

578 

579 

580@dataclass(frozen=True, slots=True, init=False) 

581class OneOf(JsonSchemaType): 

582 """ 

583 A union of possible values, equivalent to ``oneOf`` schema. 

584 

585 """ 

586 

587 precedence = 2 

588 

589 items: _t.Sequence[JsonSchemaType] 

590 """ 

591 Inner items. 

592 

593 """ 

594 

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 

611 

612 def render(self) -> dict[str, JsonValue]: 

613 return {"oneOf": [item.render() for item in self.items]} 

614 

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 

623 

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 ) 

629 

630 

631@dataclass(frozen=True, slots=True, init=False) 

632class AllOf(JsonSchemaType): 

633 """ 

634 An intersection of possible values, equivalent to ``allOf`` schema. 

635 

636 """ 

637 

638 precedence = 1 

639 

640 items: _t.Sequence[JsonSchemaType] 

641 """ 

642 Inner items. 

643 

644 """ 

645 

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 

662 

663 def render(self) -> dict[str, JsonValue]: 

664 return {"allOf": [item.render() for item in self.items]} 

665 

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 

674 

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 ) 

680 

681 

682@dataclass(frozen=True, slots=True, init=False) 

683class AnyOf(JsonSchemaType): 

684 """ 

685 A union of possible values, equivalent to ``anyOf`` schema. 

686 

687 """ 

688 

689 precedence = 2 

690 

691 items: _t.Sequence[JsonSchemaType] 

692 """ 

693 Inner items. 

694 

695 """ 

696 

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 

713 

714 def render(self) -> dict[str, JsonValue]: 

715 return {"anyOf": [item.render() for item in self.items]} 

716 

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 

725 

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 ) 

731 

732 

733@dataclass(frozen=True, slots=True) 

734class Enum(JsonSchemaType): 

735 """ 

736 An enum of primitive constants. 

737 

738 """ 

739 

740 precedence = 2 

741 

742 constants: _t.Sequence[str | int | float | bool | None] 

743 """ 

744 Enum elements. 

745 

746 """ 

747 

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. 

752 

753 """ 

754 

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 } 

769 

770 def pprint(self) -> str: 

771 return " | ".join(f"{json.dumps(item)}" for item in self.constants) 

772 

773 

774@dataclass(frozen=True, slots=True) 

775class Object(JsonSchemaType): 

776 """ 

777 An object, usually represents a :class:`~yuio.config.Config`. 

778 

779 """ 

780 

781 properties: dict[str, JsonSchemaType] 

782 """ 

783 Object keys and their types. 

784 

785 """ 

786 

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 } 

795 

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 

806 

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}}}" 

812 

813 

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. 

819 

820 """ 

821 

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. 

826 

827 """ 

828 

829 def render(self) -> dict[str, JsonValue]: 

830 return self.schema 

831 

832 def remove_opaque(self) -> JsonSchemaType | None: 

833 return None 

834 

835 def pprint(self) -> str: 

836 return "..." 

837 

838 

839@dataclass(frozen=True, slots=True) 

840class Meta(JsonSchemaType): 

841 """ 

842 Adds title, description and defaults to the wrapped schema. 

843 

844 """ 

845 

846 item: JsonSchemaType 

847 """ 

848 Inner type. 

849 

850 """ 

851 

852 title: str | None = None 

853 """ 

854 Title for the wrapped item. 

855 

856 """ 

857 

858 description: str | None = None 

859 """ 

860 Description for the wrapped item. 

861 

862 """ 

863 

864 default: JsonValue | yuio.Missing = yuio.MISSING 

865 """ 

866 Default value for the wrapped item. 

867 

868 """ 

869 

870 @property 

871 def precedence(self): # pyright: ignore[reportIncompatibleVariableOverride] 

872 return 3 if self.title else self.item.precedence 

873 

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 

883 

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 

890 

891 def pprint(self) -> str: 

892 return self.title or self.item.pprint()