Coverage for jsonschema_diff / sphinx / directive.py: 0%

68 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 17:05 +0000

1# jsonschema_diff/sphinx/directive.py 

2"""Sphinx directive to embed a Rich‑rendered JSON‑schema diff as SVG. 

3 

4Usage:: 

5 

6 .. jsonschemadiff:: old.json new.json 

7 :name: my_diff.svg # optional custom file name (".svg" can be omitted) 

8 :title: Schema Diff # title shown inside the virtual terminal tab 

9 :no-body: # hide diff body, keep legend only 

10 :no-legend: # hide legend, show body only 

11 :width: 80% # pass through to the resulting <img> tag 

12 

13All options are optional; sensible defaults are applied when omitted. 

14""" 

15 

16from __future__ import annotations 

17 

18import hashlib 

19import io 

20import shutil 

21from pathlib import Path 

22from typing import Callable, List, Optional 

23 

24from docutils import nodes 

25from docutils.parsers.rst import Directive, directives 

26from rich.console import Console, Group 

27from sphinx.errors import ExtensionError 

28from sphinx.util import logging 

29 

30__all__ = ["JsonSchemaDiffDirective"] 

31 

32LOGGER = logging.getLogger(__name__) 

33 

34 

35class JsonSchemaDiffDirective(Directive): 

36 """Embed an SVG diff between two JSON‑schema files.""" 

37 

38 has_content = False 

39 required_arguments = 2 # <old schema> <new schema> 

40 option_spec = { 

41 # behaviour flags 

42 "no-legend": directives.flag, 

43 "no-body": directives.flag, 

44 # styling / output options 

45 "width": directives.unchanged, 

46 "name": directives.unchanged, 

47 "title": directives.unchanged, 

48 } 

49 

50 _STATIC_SUBDIR = Path("_static") / "jsonschema_diff" 

51 _CONSOLE_WIDTH = 120 

52 

53 # --------------------------------------------------------------------- 

54 def run(self) -> List[nodes.Node]: # noqa: D401 

55 env = self.state.document.settings.env 

56 srcdir = Path(env.srcdir) 

57 

58 old_path = (srcdir / self.arguments[0]).resolve() 

59 new_path = (srcdir / self.arguments[1]).resolve() 

60 if not old_path.exists() or not new_path.exists(): 

61 raise self.error(f"JSON‑schema file not found: {old_path} / {new_path}") 

62 

63 # ------------------------------------------------------------------ 

64 # Retrieve configured diff object from conf.py 

65 diff = getattr(env.app.config, "jsonschema_diff", None) 

66 if diff is None: 

67 raise ExtensionError( 

68 "Define variable `jsonschema_diff` (JsonSchemaDiff instance) in conf.py." 

69 ) 

70 

71 from jsonschema_diff import JsonSchemaDiff # pylint: disable=import-outside-toplevel 

72 

73 if not isinstance(diff, JsonSchemaDiff): 

74 raise ExtensionError("`jsonschema_diff` is not a JsonSchemaDiff instance.") 

75 

76 # ------------------------------------------------------------------ 

77 # Produce Rich renderables 

78 diff.compare(str(old_path), str(new_path)) 

79 renderables: list = [] 

80 body = diff.rich_render() 

81 if "no-body" not in self.options: 

82 renderables.append(body) 

83 if "no-legend" not in self.options and hasattr(diff, "rich_legend"): 

84 renderables.append(diff.rich_legend(diff.last_compare_list)) 

85 if not renderables: 

86 return [] 

87 

88 # ------------------------------------------------------------------ 

89 # Use Rich to create SVG 

90 console = Console(record=True, width=self._CONSOLE_WIDTH, file=io.StringIO()) 

91 console.print(Group(*renderables)) 

92 

93 export_kwargs = { 

94 "title": self.options.get("title", "Rich"), 

95 "clear": False, 

96 } 

97 

98 svg_code = console.export_svg(**export_kwargs) 

99 

100 # ------------------------------------------------------------------ 

101 # Save SVG to _static/jsonschema_diff 

102 static_dir = Path(env.app.srcdir) / self._STATIC_SUBDIR 

103 if not hasattr(env.app, "_jsonschema_diff_cleaned"): 

104 shutil.rmtree(static_dir, ignore_errors=True) 

105 env.app._jsonschema_diff_cleaned = True 

106 static_dir.mkdir(parents=True, exist_ok=True) 

107 

108 svg_name = self._make_svg_name(old_path, new_path, console.export_text) 

109 svg_path = static_dir / svg_name 

110 svg_path.write_text(svg_code, encoding="utf-8") 

111 

112 # ------------------------------------------------------------------ 

113 # Insert <img> node with correct relative URI 

114 doc_depth = env.docname.count("/") 

115 uri_prefix = "../" * doc_depth 

116 img_uri = f"{uri_prefix}_static/jsonschema_diff/{svg_name}" 

117 

118 img_node = nodes.image(uri=img_uri, alt=f"diff {old_path.name}") 

119 if "width" in self.options: 

120 img_node["width"] = self.options["width"] 

121 return [img_node] 

122 

123 # ------------------------------------------------------------------ 

124 def _make_svg_name( 

125 self, 

126 old_path: Path, 

127 new_path: Path, 

128 export_text: Callable, 

129 ) -> str: 

130 """Return custom name (if provided) or deterministic hash‑based name.""" 

131 custom_name: Optional[str] = self.options.get("name") 

132 if custom_name and not custom_name.lower().endswith(".svg"): 

133 custom_name += ".svg" 

134 if custom_name: 

135 return custom_name 

136 digest = hashlib.md5(export_text(clear=False).encode()).hexdigest()[:8] 

137 return f"{old_path.stem}-{new_path.stem}-{digest}.svg"