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

68 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-25 07:00 +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""" 

15from __future__ import annotations 

16 

17import hashlib 

18import io 

19import shutil 

20from pathlib import Path 

21from typing import Callable, List, Optional 

22 

23from docutils import nodes 

24from docutils.parsers.rst import Directive, directives 

25from rich.console import Console, Group 

26from sphinx.errors import ExtensionError 

27from sphinx.util import logging 

28 

29__all__ = ["JsonSchemaDiffDirective"] 

30 

31LOGGER = logging.getLogger(__name__) 

32 

33 

34class JsonSchemaDiffDirective(Directive): 

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

36 

37 has_content = False 

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

39 option_spec = { 

40 # behaviour flags 

41 "no-legend": directives.flag, 

42 "no-body": directives.flag, 

43 # styling / output options 

44 "width": directives.unchanged, 

45 "name": directives.unchanged, 

46 "title": directives.unchanged, 

47 } 

48 

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

50 _CONSOLE_WIDTH = 120 

51 

52 # --------------------------------------------------------------------- 

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

54 env = self.state.document.settings.env 

55 srcdir = Path(env.srcdir) 

56 

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

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

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

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

61 

62 # ------------------------------------------------------------------ 

63 # Retrieve configured diff object from conf.py 

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

65 if diff is None: 

66 raise ExtensionError( 

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

68 ) 

69 

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

71 

72 if not isinstance(diff, JsonSchemaDiff): 

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

74 

75 # ------------------------------------------------------------------ 

76 # Produce Rich renderables 

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

78 renderables: list = [] 

79 body = diff.rich_render() 

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

81 renderables.append(body) 

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

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

84 if not renderables: 

85 return [] 

86 

87 # ------------------------------------------------------------------ 

88 # Use Rich to create SVG 

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

90 console.print(Group(*renderables)) 

91 

92 export_kwargs = { 

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

94 "clear": False, 

95 } 

96 

97 svg_code = console.export_svg(**export_kwargs) 

98 

99 # ------------------------------------------------------------------ 

100 # Save SVG to _static/jsonschema_diff 

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

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

103 shutil.rmtree(static_dir, ignore_errors=True) 

104 env.app._jsonschema_diff_cleaned = True 

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

106 

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

108 svg_path = static_dir / svg_name 

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

110 

111 # ------------------------------------------------------------------ 

112 # Insert <img> node with correct relative URI 

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

114 uri_prefix = "../" * doc_depth 

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

116 

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

118 if "width" in self.options: 

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

120 return [img_node] 

121 

122 # ------------------------------------------------------------------ 

123 def _make_svg_name( 

124 self, 

125 old_path: Path, 

126 new_path: Path, 

127 export_text: Callable, 

128 ) -> str: 

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

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

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

132 custom_name += ".svg" 

133 if custom_name: 

134 return custom_name 

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

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