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
« 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.
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"""
15from __future__ import annotations
17import hashlib
18import io
19import shutil
20from pathlib import Path
21from typing import Callable, List, Optional
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
29__all__ = ["JsonSchemaDiffDirective"]
31LOGGER = logging.getLogger(__name__)
34class JsonSchemaDiffDirective(Directive):
35 """Embed an SVG diff between two JSON‑schema files."""
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 }
49 _STATIC_SUBDIR = Path("_static") / "jsonschema_diff"
50 _CONSOLE_WIDTH = 120
52 # ---------------------------------------------------------------------
53 def run(self) -> List[nodes.Node]: # noqa: D401
54 env = self.state.document.settings.env
55 srcdir = Path(env.srcdir)
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}")
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 )
70 from jsonschema_diff import JsonSchemaDiff # pylint: disable=import-outside-toplevel
72 if not isinstance(diff, JsonSchemaDiff):
73 raise ExtensionError("`jsonschema_diff` is not a JsonSchemaDiff instance.")
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 []
87 # ------------------------------------------------------------------
88 # Use Rich to create SVG
89 console = Console(record=True, width=self._CONSOLE_WIDTH, file=io.StringIO())
90 console.print(Group(*renderables))
92 export_kwargs = {
93 "title": self.options.get("title", "Rich"),
94 "clear": False,
95 }
97 svg_code = console.export_svg(**export_kwargs)
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)
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")
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}"
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]
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"