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

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 return Ref(f"#/$defs/{self._types[key][0]}", self._types[key][0]) 

220 

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

222 """ 

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

224 

225 :param ref: 

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

227 :returns: 

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

229 

230 """ 

231 

232 return self._defs.get(ref) 

233 

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. 

243 

244 :returns: 

245 complete JSON representation of a schema. 

246 

247 """ 

248 

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 

257 

258 

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

260class JsonSchemaType(abc.ABC): 

261 """ 

262 Base class for JSON schema representation. 

263 

264 """ 

265 

266 precedence: _ClassVar[int] = 3 

267 """ 

268 Precedence, used for pretty-printing types. 

269 

270 """ 

271 

272 @abc.abstractmethod 

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

274 """ 

275 Serialize type as JSON. 

276 

277 """ 

278 

279 raise NotImplementedError() 

280 

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

282 """ 

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

284 

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

286 

287 """ 

288 

289 return self 

290 

291 @abc.abstractmethod 

292 def pprint(self) -> str: 

293 """ 

294 Pretty-print this type using TypeScript syntax. 

295 

296 """ 

297 

298 raise NotImplementedError() 

299 

300 def __str__(self) -> str: 

301 return self.pprint() 

302 

303 

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

305class Ref(JsonSchemaType): 

306 """ 

307 A reference to a sub-schema. 

308 

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

310 

311 """ 

312 

313 ref: str 

314 """ 

315 Referenced type. 

316 

317 """ 

318 

319 name: str | None = None 

320 """ 

321 Name of the referenced type, used for debug. 

322 

323 """ 

324 

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

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

327 

328 def pprint(self) -> str: 

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

330 

331 

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

333class Array(JsonSchemaType): 

334 """ 

335 An array or a set of values. 

336 

337 """ 

338 

339 item: JsonSchemaType 

340 """ 

341 Type of array elements. 

342 

343 """ 

344 

345 unique_items: bool = False 

346 """ 

347 Whether all array items should be unique. 

348 

349 """ 

350 

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 

356 

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 

360 

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

366 

367 

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

369class Tuple(JsonSchemaType): 

370 """ 

371 A tuple. 

372 

373 """ 

374 

375 items: _t.Sequence[JsonSchemaType] 

376 """ 

377 Types of tuple elements. 

378 

379 """ 

380 

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 } 

389 

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 ) 

398 

399 def pprint(self) -> str: 

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

401 

402 

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. 

408 

409 """ 

410 

411 key: JsonSchemaType 

412 """ 

413 Type of dict keys. 

414 

415 """ 

416 

417 value: JsonSchemaType 

418 """ 

419 Type of dict values. 

420 

421 """ 

422 

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 

430 

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 

438 

439 def pprint(self) -> str: 

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

441 

442 

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

444class Null(JsonSchemaType): 

445 """ 

446 A ``null`` value. 

447 

448 """ 

449 

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

451 return {"type": "null"} 

452 

453 def pprint(self) -> str: 

454 return "null" 

455 

456 

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

458class Boolean(JsonSchemaType): 

459 """ 

460 A boolean value. 

461 

462 """ 

463 

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

465 return {"type": "boolean"} 

466 

467 def pprint(self) -> str: 

468 return "boolean" 

469 

470 

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

472class Number(JsonSchemaType): 

473 """ 

474 A numeric value. 

475 

476 """ 

477 

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

479 return {"type": "number"} 

480 

481 def pprint(self) -> str: 

482 return "number" 

483 

484 

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

486class Integer(Number): 

487 """ 

488 An integer value. 

489 

490 """ 

491 

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

493 return {"type": "integer"} 

494 

495 def pprint(self) -> str: 

496 return "integer" 

497 

498 

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

500class String(JsonSchemaType): 

501 """ 

502 A string value, possibly with pattern. 

503 

504 """ 

505 

506 pattern: str | None = None 

507 """ 

508 Regular expression for checking string elements. 

509 

510 """ 

511 

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 

517 

518 def pprint(self) -> str: 

519 return "string" 

520 

521 

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

523class Any(JsonSchemaType): 

524 """ 

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

526 

527 """ 

528 

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

530 return {} 

531 

532 def pprint(self) -> str: 

533 return "any" 

534 

535 

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

537class Never(JsonSchemaType): 

538 """ 

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

540 

541 """ 

542 

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

544 return {"allOf": [False]} 

545 

546 def pprint(self) -> str: 

547 return "never" 

548 

549 

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

551class OneOf(JsonSchemaType): 

552 """ 

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

554 

555 """ 

556 

557 precedence = 2 

558 

559 items: _t.Sequence[JsonSchemaType] 

560 """ 

561 Inner items. 

562 

563 """ 

564 

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 

581 

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

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

584 

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 

593 

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 ) 

599 

600 

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

602class AllOf(JsonSchemaType): 

603 """ 

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

605 

606 """ 

607 

608 precedence = 1 

609 

610 items: _t.Sequence[JsonSchemaType] 

611 """ 

612 Inner items. 

613 

614 """ 

615 

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 

632 

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

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

635 

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 

644 

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 ) 

650 

651 

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

653class AnyOf(JsonSchemaType): 

654 """ 

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

656 

657 """ 

658 

659 precedence = 2 

660 

661 items: _t.Sequence[JsonSchemaType] 

662 """ 

663 Inner items. 

664 

665 """ 

666 

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 

683 

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

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

686 

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 

695 

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 ) 

701 

702 

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

704class Enum(JsonSchemaType): 

705 """ 

706 An enum of primitive constants. 

707 

708 """ 

709 

710 precedence = 2 

711 

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

713 """ 

714 Enum elements. 

715 

716 """ 

717 

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. 

722 

723 """ 

724 

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 } 

739 

740 def pprint(self) -> str: 

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

742 

743 

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

745class Object(JsonSchemaType): 

746 """ 

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

748 

749 """ 

750 

751 properties: dict[str, JsonSchemaType] 

752 """ 

753 Object keys and their types. 

754 

755 """ 

756 

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 } 

765 

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 

776 

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

782 

783 

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. 

789 

790 """ 

791 

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. 

796 

797 """ 

798 

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

800 return self.schema 

801 

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

803 return None 

804 

805 def pprint(self) -> str: 

806 return "..." 

807 

808 

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

810class Meta(JsonSchemaType): 

811 """ 

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

813 

814 """ 

815 

816 item: JsonSchemaType 

817 """ 

818 Inner type. 

819 

820 """ 

821 

822 title: str | None = None 

823 """ 

824 Title for the wrapped item. 

825 

826 """ 

827 

828 description: str | None = None 

829 """ 

830 Description for the wrapped item. 

831 

832 """ 

833 

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

835 """ 

836 Default value for the wrapped item. 

837 

838 """ 

839 

840 @property 

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

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

843 

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 

853 

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 

860 

861 def pprint(self) -> str: 

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