jsonschema-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. jsonschema_diff/__init__.py +17 -0
  2. jsonschema_diff/cli.py +190 -0
  3. jsonschema_diff/color/__init__.py +12 -0
  4. jsonschema_diff/color/abstraction.py +62 -0
  5. jsonschema_diff/color/base.py +124 -0
  6. jsonschema_diff/color/stages/__init__.py +31 -0
  7. jsonschema_diff/color/stages/mono_lines.py +112 -0
  8. jsonschema_diff/color/stages/path.py +191 -0
  9. jsonschema_diff/color/stages/replace.py +184 -0
  10. jsonschema_diff/config_maker.py +100 -0
  11. jsonschema_diff/core/__init__.py +7 -0
  12. jsonschema_diff/core/abstraction.py +51 -0
  13. jsonschema_diff/core/config.py +100 -0
  14. jsonschema_diff/core/custom_compare/__init__.py +7 -0
  15. jsonschema_diff/core/custom_compare/list.py +98 -0
  16. jsonschema_diff/core/custom_compare/range.py +241 -0
  17. jsonschema_diff/core/parameter_base.py +118 -0
  18. jsonschema_diff/core/parameter_combined.py +36 -0
  19. jsonschema_diff/core/property.py +243 -0
  20. jsonschema_diff/core/tools/__init__.py +6 -0
  21. jsonschema_diff/core/tools/combine.py +105 -0
  22. jsonschema_diff/core/tools/compare.py +77 -0
  23. jsonschema_diff/core/tools/context.py +127 -0
  24. jsonschema_diff/core/tools/render.py +128 -0
  25. jsonschema_diff/pypi_interface.py +187 -0
  26. jsonschema_diff/sphinx/__init__.py +32 -0
  27. jsonschema_diff/sphinx/directive.py +136 -0
  28. jsonschema_diff/table_render.py +227 -0
  29. jsonschema_diff-0.1.0.dist-info/METADATA +292 -0
  30. jsonschema_diff-0.1.0.dist-info/RECORD +34 -0
  31. jsonschema_diff-0.1.0.dist-info/WHEEL +5 -0
  32. jsonschema_diff-0.1.0.dist-info/entry_points.txt +2 -0
  33. jsonschema_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
  34. jsonschema_diff-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,17 @@
1
+ from importlib import import_module
2
+ from types import ModuleType
3
+
4
+ from .config_maker import ConfigMaker
5
+ from .pypi_interface import JsonSchemaDiff
6
+
7
+
8
+ # Lazy-import подмодуля sphinx (тянет Rich → Sphinx только по требованию)
9
+ def __getattr__(name: str) -> ModuleType: # pragma: no cover
10
+ if name == "sphinx":
11
+ return import_module("jsonschema_diff.sphinx")
12
+ raise AttributeError(name)
13
+
14
+
15
+ __all__ = ["JsonSchemaDiff", "ConfigMaker"]
16
+
17
+ __version__ = "0.1.0"
jsonschema_diff/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ jsonschema_diff CLI
3
+ ===================
4
+
5
+ A tiny command-line front-end around :py:mod:`jsonschema_diff`
6
+ that highlights semantic differences between two JSON-Schema
7
+ documents directly in your terminal.
8
+
9
+ Typical usage
10
+ -------------
11
+ >>> jsonschema-diff old.schema.json new.schema.json
12
+ >>> jsonschema-diff --no-color --legend old.json new.json
13
+ >>> jsonschema-diff --exit-code old.json new.json # useful in CI
14
+
15
+ Exit status
16
+ -----------
17
+ * **0** – the two schemas are semantically identical
18
+ * **1** – at least one difference was detected (only when
19
+ ``--exit-code`` is given)
20
+
21
+ The CLI is intentionally minimal: *all* comparison options are taken
22
+ from :pyclass:`jsonschema_diff.ConfigMaker`, so the behaviour stays
23
+ in sync with the library defaults.
24
+
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import sys
32
+
33
+ from jsonschema_diff import ConfigMaker, JsonSchemaDiff
34
+ from jsonschema_diff.color import HighlighterPipeline
35
+ from jsonschema_diff.color.stages import (
36
+ MonoLinesHighlighter,
37
+ PathHighlighter,
38
+ ReplaceGenericHighlighter,
39
+ )
40
+ from jsonschema_diff.core.parameter_base import Compare
41
+
42
+
43
+ def _make_highlighter(disable_color: bool) -> HighlighterPipeline:
44
+ """
45
+ Create the high-lighting pipeline used to colorise diff output.
46
+
47
+ Parameters
48
+ ----------
49
+ disable_color :
50
+ When *True* ANSI escape sequences are suppressed even if the
51
+ invoking TTY advertises color support (e.g. when piping the
52
+ output into a file).
53
+
54
+ Returns
55
+ -------
56
+ HighlighterPipeline
57
+ Either an **empty** pipeline (no colour) or the standard
58
+ three-stage pipeline consisting of
59
+ :class:`~jsonschema_diff.color.stages.MonoLinesHighlighter`,
60
+ :class:`~jsonschema_diff.color.stages.ReplaceGenericHighlighter`
61
+ and :class:`~jsonschema_diff.color.stages.PathHighlighter`.
62
+
63
+ Note
64
+ -----
65
+ The composition of the *default* pipeline mirrors what the core
66
+ library exposes; duplicating the stages here keeps the CLI fully
67
+ self-contained while allowing future customisation.
68
+
69
+ Examples
70
+ --------
71
+ >>> _make_highlighter(True)
72
+ HighlighterPipeline(stages=[])
73
+ >>> _make_highlighter(False).stages # doctest: +ELLIPSIS
74
+ [<jsonschema_diff.color.stages.MonoLinesHighlighter ...>, ...]
75
+ """
76
+ if disable_color:
77
+ return HighlighterPipeline([])
78
+ return HighlighterPipeline(
79
+ [
80
+ MonoLinesHighlighter(),
81
+ ReplaceGenericHighlighter(),
82
+ PathHighlighter(),
83
+ ]
84
+ )
85
+
86
+
87
+ def _build_parser() -> argparse.ArgumentParser:
88
+ """
89
+ Construct the :pyclass:`argparse.ArgumentParser` for the CLI.
90
+
91
+ Returns
92
+ -------
93
+ argparse.ArgumentParser
94
+ The fully configured parser containing positional arguments
95
+ for the *old* and *new* schema paths, together with three
96
+ optional feature flags.
97
+
98
+ See Also
99
+ --------
100
+ * :pyfunc:`main` – where the parser is consumed.
101
+ * The *argparse* documentation for available formatting options.
102
+ """
103
+ p = argparse.ArgumentParser(
104
+ prog="jsonschema-diff",
105
+ description="Show the difference between two JSON-Schema files",
106
+ )
107
+
108
+ # Positional arguments
109
+ p.add_argument("old_schema", help="Path to the *old* schema")
110
+ p.add_argument("new_schema", help="Path to the *new* schema")
111
+
112
+ # Output options
113
+ p.add_argument(
114
+ "--no-color",
115
+ action="store_true",
116
+ help="Disable ANSI colors even if the terminal supports them",
117
+ )
118
+ p.add_argument(
119
+ "--legend",
120
+ action="store_true",
121
+ help="Print a legend explaining diff symbols at the end",
122
+ )
123
+
124
+ # Exit-code control
125
+ p.add_argument(
126
+ "--exit-code",
127
+ action="store_true",
128
+ help="Return **1** if differences are detected, otherwise **0**",
129
+ )
130
+
131
+ return p
132
+
133
+
134
+ def main(argv: list[str] | None = None) -> None: # pragma: no cover
135
+ """
136
+ CLI entry-point (invoked by ``python -m jsonschema_diff`` or by the
137
+ ``jsonschema-diff`` console script).
138
+
139
+ Parameters
140
+ ----------
141
+ argv :
142
+ Command-line argument vector **excluding** the executable name.
143
+ When *None* (default) ``sys.argv[1:]`` is used – this is the
144
+ behaviour required by *setuptools* console-scripts.
145
+
146
+
147
+ Note
148
+ ----
149
+ The function performs four sequential steps:
150
+
151
+ 1. Build a :class:`JsonSchemaDiff` instance.
152
+ 2. Compare the two user-supplied schema files.
153
+ 3. Print a colourised diff (optionally with a legend).
154
+ 4. Optionally exit with code 1 if differences are present.
155
+
156
+ """
157
+ args = _build_parser().parse_args(argv)
158
+
159
+ # 1. Build the wrapper object
160
+ diff = JsonSchemaDiff(
161
+ config=ConfigMaker.make(),
162
+ colorize_pipeline=_make_highlighter(args.no_color),
163
+ legend_ignore=[Compare], # as in the library example
164
+ )
165
+
166
+ def try_load(data: str) -> dict | str:
167
+ try:
168
+ return dict(json.loads(data))
169
+ except json.JSONDecodeError:
170
+ return str(data)
171
+
172
+ # 2. Compare the files
173
+ diff.compare(
174
+ old_schema=try_load(args.old_schema),
175
+ new_schema=try_load(args.new_schema),
176
+ )
177
+
178
+ # 3. Print the result
179
+ diff.print(
180
+ with_legend=args.legend,
181
+ )
182
+
183
+ # 4. Optional special exit code
184
+ if args.exit_code:
185
+ # ``last_compare_list`` is filled during render/print.
186
+ sys.exit(1 if diff.last_compare_list else 0)
187
+
188
+
189
+ if __name__ == "__main__": # pragma: no cover
190
+ main()
@@ -0,0 +1,12 @@
1
+ """
2
+ Convenience re-exports for the colour sub-package.
3
+
4
+ End-users can simply write::
5
+
6
+ from jsonschema_diff.color import HighlighterPipeline, LineHighlighter
7
+ """
8
+
9
+ from .abstraction import LineHighlighter
10
+ from .base import HighlighterPipeline
11
+
12
+ __all__ = ["HighlighterPipeline", "LineHighlighter"]
@@ -0,0 +1,62 @@
1
+ from typing import List, Protocol, Sequence
2
+
3
+ from rich.text import Text
4
+
5
+ """
6
+ Abstraction for line-based high-lighters
7
+ =======================================
8
+
9
+ This module defines :class:`LineHighlighter`, a thin
10
+ :class:`typing.Protocol` that specifies the minimal contract required by the
11
+ colour pipeline implemented in :pyfile:`base.py`.
12
+
13
+ Implementors are expected to decorate :class:`rich.text.Text` objects **in
14
+ place**; therefore methods must not alter the underlying string content –
15
+ only styling metadata.
16
+ """
17
+
18
+
19
+ class LineHighlighter(Protocol):
20
+ """Protocol for single-line high-lighters.
21
+
22
+ Concrete implementations *may* also override
23
+ :meth:`colorize_lines` for bulk operations, but
24
+ :meth:`colorize_line` is the only mandatory method.
25
+ """
26
+
27
+ def colorize_line(self, line: Text) -> Text:
28
+ """Stylise one line **in-place** and return it.
29
+
30
+ Parameters
31
+ ----------
32
+ line :
33
+ A single :class:`rich.text.Text` instance to be colour-styled.
34
+
35
+ Returns
36
+ -------
37
+ rich.text.Text
38
+ The **same** `Text` object, now containing style spans.
39
+
40
+ Raises
41
+ ------
42
+ NotImplementedError
43
+ Always here; concrete subclasses must override this method.
44
+ """
45
+ raise NotImplementedError("LineHighlighter.colorize_line должен быть переопределен")
46
+
47
+ def colorize_lines(self, lines: Sequence[Text]) -> List[Text]:
48
+ """Vectorised helper that stylises a *sequence* of lines.
49
+
50
+ The naïve fallback simply delegates to :meth:`colorize_line`.
51
+
52
+ Parameters
53
+ ----------
54
+ lines :
55
+ Ordered collection of :class:`rich.text.Text` objects.
56
+
57
+ Returns
58
+ -------
59
+ list[rich.text.Text]
60
+ The original objects, now styled in place.
61
+ """
62
+ return [self.colorize_line(t) for t in lines]
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Composable *Rich*-native colouring pipeline
5
+ ==========================================
6
+
7
+ Provides :class:`HighlighterPipeline`, an orchestrator that feeds raw
8
+ multi-line strings through a chain of :class:`LineHighlighter` stages.
9
+ Each stage operates on :class:`rich.text.Text` so it can add style spans
10
+ without mutating the text itself.
11
+
12
+ Typical usage
13
+ -------------
14
+
15
+ >>> pipeline = HighlighterPipeline([MySyntaxHL(), MyDiffHL()])
16
+ >>> ansi_output = pipeline.colorize_and_render(src_string)
17
+ print(ansi_output)
18
+ """
19
+
20
+ from typing import TYPE_CHECKING, Iterable
21
+
22
+ from rich.console import Console
23
+ from rich.text import Text
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ from .abstraction import LineHighlighter # noqa: F401 (imported for typing only)
27
+
28
+
29
+ class HighlighterPipeline: # noqa: D101
30
+ """Chain of :pyclass:`LineHighlighter` stages.
31
+
32
+ Parameters
33
+ ----------
34
+ stages :
35
+ Iterable of :class:`LineHighlighter` instances. The iterable is
36
+ immediately materialised into a list so the pipeline can be reused.
37
+
38
+ Note
39
+ ----
40
+ * Each input line is passed through **every** stage in order.
41
+ * If a stage exposes a bulk :pyfunc:`colorize_lines` method it is
42
+ preferred over per-line iteration for performance.
43
+ """
44
+
45
+ def __init__(self, stages: Iterable["LineHighlighter"]):
46
+ self.stages: list["LineHighlighter"] = list(stages)
47
+
48
+ # ------------------------------------------------------------------
49
+ # Public helpers
50
+ # ------------------------------------------------------------------
51
+ def colorize(self, text: str) -> Text: # noqa: D401
52
+ """Return a rich ``Text`` object with all styles applied.
53
+
54
+ Parameters
55
+ ----------
56
+ text :
57
+ Multi-line string to be colourised.
58
+
59
+ Returns
60
+ -------
61
+ One composite `Text` built by joining all styled lines with
62
+ ``\\n`` separators.
63
+ """
64
+ lines = text.splitlines()
65
+ rich_lines = [Text(line) for line in lines]
66
+
67
+ for stage in self.stages:
68
+ colorize_lines = getattr(stage, "colorize_lines", None)
69
+ if callable(colorize_lines):
70
+ colorize_lines(rich_lines)
71
+ else:
72
+ for rl in rich_lines:
73
+ stage.colorize_line(rl)
74
+ return Text("\n").join(rich_lines)
75
+
76
+ def colorize_and_render(self, text: str) -> str:
77
+ """Colourise and immediately render to ANSI.
78
+
79
+ Parameters
80
+ ----------
81
+ text :
82
+ Multi-line input string.
83
+
84
+ Returns
85
+ -------
86
+ ANSI-encoded string ready for terminal output.
87
+ """
88
+ rich_lines = self.colorize(text)
89
+
90
+ console = Console(
91
+ force_terminal=True,
92
+ color_system="truecolor",
93
+ width=self._detect_width(),
94
+ legacy_windows=False,
95
+ )
96
+ with console.capture() as cap:
97
+ console.print(rich_lines, end="")
98
+ return cap.get()
99
+
100
+ # ------------------------------------------------------------------
101
+ # Internal helpers
102
+ # ------------------------------------------------------------------
103
+ @staticmethod
104
+ def _detect_width(default: int = 512) -> int: # noqa: D401
105
+ """Best-effort terminal width detection.
106
+
107
+ Falls back to *default* when a real TTY is not present
108
+ (e.g. in CI).
109
+
110
+ Parameters
111
+ ----------
112
+ default :
113
+ Width to use when detection fails. Defaults to ``512``.
114
+
115
+ Returns
116
+ -------
117
+ Column count deemed safe for rendering.
118
+ """
119
+ try:
120
+ from shutil import get_terminal_size
121
+
122
+ return max(get_terminal_size().columns, 20)
123
+ except Exception: # pragma: no cover
124
+ return default
@@ -0,0 +1,31 @@
1
+ """
2
+ Built-in high-lighter stages
3
+ ============================
4
+
5
+ This *sub-package* bundles three ready-to-use implementations of
6
+ :class:`jsonschema_diff.color.abstraction.LineHighlighter` that cover the most
7
+ common needs when rendering a JSON-Schema diff to the terminal:
8
+
9
+ * :class:`MonoLinesHighlighter` – apply a foreground colour chosen from a
10
+ *prefix → colour* mapping
11
+ * :class:`ReplaceGenericHighlighter` – highlight token-level changes within
12
+ ``OLD -> NEW`` tails
13
+ * :class:`PathHighlighter` – pretty-print JSON-Pointer-like paths
14
+
15
+ Importing the package directly re-exports these classes so you can write
16
+ succinct code such as ::
17
+
18
+ from jsonschema_diff.color.stages import MonoLinesHighlighter
19
+
20
+ Only the three public classes below are exported via :py:data:`__all__`.
21
+ """
22
+
23
+ from .mono_lines import MonoLinesHighlighter
24
+ from .path import PathHighlighter
25
+ from .replace import ReplaceGenericHighlighter
26
+
27
+ __all__: list[str] = [
28
+ "MonoLinesHighlighter",
29
+ "ReplaceGenericHighlighter",
30
+ "PathHighlighter",
31
+ ]
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Monochrome prefix-based high-lighter
5
+ ====================================
6
+
7
+ A :class:`~jsonschema_diff.color.abstraction.LineHighlighter` that decorates a
8
+ single :class:`rich.text.Text` *in-place* by looking at its **first matching
9
+ prefix**—typically the leading character produced by *unified diff* output
10
+ (``-``, ``+``, *etc.*).
11
+
12
+ Why a “Rich-native” rewrite?
13
+ ----------------------------
14
+ The original *jsonschema-diff* implementation rendered to a string containing
15
+ ANSI escape codes. In interactive TUI applications you often want to keep the
16
+ object as a real ``Text`` so you can:
17
+
18
+ * put it into a :class:`rich.table.Table`,
19
+ * display it inside a :class:`rich.panel.Panel`,
20
+ * or update it live without re-parsing ANSI.
21
+
22
+ This drop-in replacement keeps behaviour identical while removing the ANSI
23
+ round-trip.
24
+ """
25
+ from typing import Mapping, Optional
26
+
27
+ from rich.style import Style
28
+ from rich.text import Text
29
+
30
+ from ..abstraction import LineHighlighter
31
+
32
+
33
+ class MonoLinesHighlighter(LineHighlighter):
34
+ """Colourise one line based on a prefix lookup.
35
+
36
+ Parameters
37
+ ----------
38
+ bold :
39
+ Apply the *bold* attribute together with the foreground colour.
40
+ Enabled by default to keep parity with the original.
41
+ default_color :
42
+ Fallback colour when no rule matches. If *None* the line is left
43
+ unchanged (except for *bold* when that option is *True*).
44
+ case_sensitive :
45
+ Whether prefix matching should be case-sensitive. Defaults to *False*.
46
+ rules :
47
+ Mapping ``prefix → colour``. The first match wins; order is therefore
48
+ significant. If *None*, the following defaults are used::
49
+
50
+ {
51
+ "-": "red",
52
+ "+": "green",
53
+ "r": "cyan",
54
+ "m": "cyan",
55
+ }
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ bold: bool = True,
61
+ default_color: Optional[str] = None,
62
+ case_sensitive: bool = False,
63
+ rules: Mapping[str, str] | None = None,
64
+ ) -> None:
65
+ if rules is None:
66
+ rules = {
67
+ "-": "red",
68
+ "+": "green",
69
+ "r": "cyan",
70
+ "m": "cyan",
71
+ }
72
+ self.bold = bold
73
+ self.default_color = default_color
74
+ self.case_sensitive = case_sensitive
75
+ self.rules: Mapping[str, str] = dict(rules) # preserve order
76
+
77
+ # ------------------------------------------------------------------
78
+ # Public API
79
+ # ------------------------------------------------------------------
80
+ def colorize_line(self, line: Text) -> Text:
81
+ """Apply a single style pass **in place**.
82
+
83
+ Parameters
84
+ ----------
85
+ line :
86
+ The :class:`rich.text.Text` instance to be modified.
87
+
88
+ Returns
89
+ -------
90
+ rich.text.Text
91
+ **The very same instance** that was passed in—allowing fluent,
92
+ chainable APIs.
93
+
94
+ Note
95
+ ----
96
+ Only the *first* matching prefix is honoured; subsequent rules are
97
+ ignored, mirroring classic *grep* / *sed* behaviour.
98
+ """
99
+ probe = line.plain if self.case_sensitive else line.plain.lower()
100
+
101
+ for prefix, color in self.rules.items():
102
+ pref = prefix if self.case_sensitive else prefix.lower()
103
+ if probe.startswith(pref):
104
+ line.stylize(Style(color=color, bold=self.bold), 0, len(line))
105
+ return line # first match wins
106
+
107
+ # --- fall-back -------------------------------------------------
108
+ if self.default_color is not None:
109
+ line.stylize(Style(color=self.default_color, bold=self.bold), 0, len(line))
110
+ elif self.bold:
111
+ line.stylize(Style(bold=True), 0, len(line))
112
+ return line