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

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

446 

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 

461 

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 

469 

470 def pprint(self) -> str: 

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

472 

473 

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 ) 

480 

481 

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

483class Null(JsonSchemaType): 

484 """ 

485 A ``null`` value. 

486 

487 """ 

488 

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

490 return {"type": "null"} 

491 

492 def pprint(self) -> str: 

493 return "null" 

494 

495 

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

497class Boolean(JsonSchemaType): 

498 """ 

499 A boolean value. 

500 

501 """ 

502 

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

504 return {"type": "boolean"} 

505 

506 def pprint(self) -> str: 

507 return "boolean" 

508 

509 

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

511class Number(JsonSchemaType): 

512 """ 

513 A numeric value. 

514 

515 """ 

516 

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

518 return {"type": "number"} 

519 

520 def pprint(self) -> str: 

521 return "number" 

522 

523 

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

525class Integer(Number): 

526 """ 

527 An integer value. 

528 

529 """ 

530 

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

532 return {"type": "integer"} 

533 

534 def pprint(self) -> str: 

535 return "integer" 

536 

537 

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

539class String(JsonSchemaType): 

540 """ 

541 A string value, possibly with pattern. 

542 

543 """ 

544 

545 pattern: str | None = None 

546 """ 

547 Regular expression for checking string elements. 

548 

549 """ 

550 

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 

556 

557 def pprint(self) -> str: 

558 return "string" 

559 

560 

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

562class Any(JsonSchemaType): 

563 """ 

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

565 

566 """ 

567 

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

569 return {} 

570 

571 def pprint(self) -> str: 

572 return "any" 

573 

574 

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

576class Never(JsonSchemaType): 

577 """ 

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

579 

580 """ 

581 

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

583 return {"allOf": [False]} 

584 

585 def pprint(self) -> str: 

586 return "never" 

587 

588 

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

590class OneOf(JsonSchemaType): 

591 """ 

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

593 

594 """ 

595 

596 precedence = 2 

597 

598 items: _t.Sequence[JsonSchemaType] 

599 """ 

600 Inner items. 

601 

602 """ 

603 

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 

620 

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

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

623 

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 

632 

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 ) 

638 

639 

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

641class AllOf(JsonSchemaType): 

642 """ 

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

644 

645 """ 

646 

647 precedence = 1 

648 

649 items: _t.Sequence[JsonSchemaType] 

650 """ 

651 Inner items. 

652 

653 """ 

654 

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 

671 

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

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

674 

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 

683 

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 ) 

689 

690 

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

692class AnyOf(JsonSchemaType): 

693 """ 

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

695 

696 """ 

697 

698 precedence = 2 

699 

700 items: _t.Sequence[JsonSchemaType] 

701 """ 

702 Inner items. 

703 

704 """ 

705 

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 

722 

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

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

725 

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 

734 

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 ) 

740 

741 

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

743class Enum(JsonSchemaType): 

744 """ 

745 An enum of primitive constants. 

746 

747 """ 

748 

749 precedence = 2 

750 

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

752 """ 

753 Enum elements. 

754 

755 """ 

756 

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. 

761 

762 """ 

763 

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 } 

778 

779 def pprint(self) -> str: 

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

781 

782 

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

784class Object(JsonSchemaType): 

785 """ 

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

787 

788 """ 

789 

790 properties: dict[str, JsonSchemaType] 

791 """ 

792 Object keys and their types. 

793 

794 """ 

795 

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 } 

804 

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 

815 

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

821 

822 

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. 

828 

829 """ 

830 

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. 

835 

836 """ 

837 

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

839 return self.schema 

840 

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

842 return None 

843 

844 def pprint(self) -> str: 

845 return "..." 

846 

847 

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

849class Meta(JsonSchemaType): 

850 """ 

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

852 

853 """ 

854 

855 item: JsonSchemaType 

856 """ 

857 Inner type. 

858 

859 """ 

860 

861 title: str | None = None 

862 """ 

863 Title for the wrapped item. 

864 

865 """ 

866 

867 description: str | None = None 

868 """ 

869 Description for the wrapped item. 

870 

871 """ 

872 

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

874 """ 

875 Default value for the wrapped item. 

876 

877 """ 

878 

879 @property 

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

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

882 

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 

892 

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 

899 

900 def pprint(self) -> str: 

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