Source code for jsonschema_diff.sphinx.directive

# jsonschema_diff/sphinx/directive.py
"""Sphinx directive to embed a Rich‑rendered JSON‑schema diff as SVG.

Usage::

    .. jsonschemadiff:: old.json new.json
       :name:   my_diff.svg   # optional custom file name (".svg" can be omitted)
       :title:  Schema Diff   # title shown inside the virtual terminal tab
       :no-body:             # hide diff body, keep legend only
       :no-legend:           # hide legend, show body only
       :width:  80%          # pass through to the resulting <img> tag

All options are optional; sensible defaults are applied when omitted.
"""
from __future__ import annotations

import hashlib
import io
import shutil
from pathlib import Path
from typing import Callable, List, Optional

from docutils import nodes
from docutils.parsers.rst import Directive, directives
from rich.console import Console, Group
from sphinx.errors import ExtensionError
from sphinx.util import logging

__all__ = ["JsonSchemaDiffDirective"]

LOGGER = logging.getLogger(__name__)


[docs] class JsonSchemaDiffDirective(Directive): """Embed an SVG diff between two JSON‑schema files."""
[docs] has_content = False
[docs] required_arguments = 2 # <old schema> <new schema>
[docs] option_spec = { # behaviour flags "no-legend": directives.flag, "no-body": directives.flag, # styling / output options "width": directives.unchanged, "name": directives.unchanged, "title": directives.unchanged, }
_STATIC_SUBDIR = Path("_static") / "jsonschema_diff" _CONSOLE_WIDTH = 120 # ---------------------------------------------------------------------
[docs] def run(self) -> List[nodes.Node]: # noqa: D401 env = self.state.document.settings.env srcdir = Path(env.srcdir) old_path = (srcdir / self.arguments[0]).resolve() new_path = (srcdir / self.arguments[1]).resolve() if not old_path.exists() or not new_path.exists(): raise self.error(f"JSON‑schema file not found: {old_path} / {new_path}") # ------------------------------------------------------------------ # Retrieve configured diff object from conf.py diff = getattr(env.app.config, "jsonschema_diff", None) if diff is None: raise ExtensionError( "Define variable `jsonschema_diff` (JsonSchemaDiff instance) in conf.py." ) from jsonschema_diff import JsonSchemaDiff # pylint: disable=import-outside-toplevel if not isinstance(diff, JsonSchemaDiff): raise ExtensionError("`jsonschema_diff` is not a JsonSchemaDiff instance.") # ------------------------------------------------------------------ # Produce Rich renderables diff.compare(str(old_path), str(new_path)) renderables: list = [] body = diff.rich_render() if "no-body" not in self.options: renderables.append(body) if "no-legend" not in self.options and hasattr(diff, "rich_legend"): renderables.append(diff.rich_legend(diff.last_compare_list)) if not renderables: return [] # ------------------------------------------------------------------ # Use Rich to create SVG console = Console(record=True, width=self._CONSOLE_WIDTH, file=io.StringIO()) console.print(Group(*renderables)) export_kwargs = { "title": self.options.get("title", "Rich"), "clear": False, } svg_code = console.export_svg(**export_kwargs) # ------------------------------------------------------------------ # Save SVG to _static/jsonschema_diff static_dir = Path(env.app.srcdir) / self._STATIC_SUBDIR if not hasattr(env.app, "_jsonschema_diff_cleaned"): shutil.rmtree(static_dir, ignore_errors=True) env.app._jsonschema_diff_cleaned = True static_dir.mkdir(parents=True, exist_ok=True) svg_name = self._make_svg_name(old_path, new_path, console.export_text) svg_path = static_dir / svg_name svg_path.write_text(svg_code, encoding="utf-8") # ------------------------------------------------------------------ # Insert <img> node with correct relative URI doc_depth = env.docname.count("/") uri_prefix = "../" * doc_depth img_uri = f"{uri_prefix}_static/jsonschema_diff/{svg_name}" img_node = nodes.image(uri=img_uri, alt=f"diff {old_path.name}") if "width" in self.options: img_node["width"] = self.options["width"] return [img_node]
# ------------------------------------------------------------------ def _make_svg_name( self, old_path: Path, new_path: Path, export_text: Callable, ) -> str: """Return custom name (if provided) or deterministic hash‑based name.""" custom_name: Optional[str] = self.options.get("name") if custom_name and not custom_name.lower().endswith(".svg"): custom_name += ".svg" if custom_name: return custom_name digest = hashlib.md5(export_text(clear=False).encode()).hexdigest()[:8] return f"{old_path.stem}-{new_path.stem}-{digest}.svg"