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
« 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.
4Usage::
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
13All options are optional; sensible defaults are applied when omitted.
14"""
16from __future__ import annotations
18import hashlib
19import io
20import shutil
21from pathlib import Path
22from typing import Callable, List, Optional
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
30__all__ = ["JsonSchemaDiffDirective"]
32LOGGER = logging.getLogger(__name__)
35class JsonSchemaDiffDirective(Directive):
36 """Embed an SVG diff between two JSON‑schema files."""
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 }
50 _STATIC_SUBDIR = Path("_static") / "jsonschema_diff"
51 _CONSOLE_WIDTH = 120
53 # ---------------------------------------------------------------------
54 def run(self) -> List[nodes.Node]: # noqa: D401
55 env = self.state.document.settings.env
56 srcdir = Path(env.srcdir)
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}")
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 )
71 from jsonschema_diff import JsonSchemaDiff # pylint: disable=import-outside-toplevel
73 if not isinstance(diff, JsonSchemaDiff):
74 raise ExtensionError("`jsonschema_diff` is not a JsonSchemaDiff instance.")
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 []
88 # ------------------------------------------------------------------
89 # Use Rich to create SVG
90 console = Console(record=True, width=self._CONSOLE_WIDTH, file=io.StringIO())
91 console.print(Group(*renderables))
93 export_kwargs = {
94 "title": self.options.get("title", "Rich"),
95 "clear": False,
96 }
98 svg_code = console.export_svg(**export_kwargs)
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)
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")
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}"
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]
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"