amithjkamath commited on
Commit
a98fbc5
·
0 Parent(s):

Initial standalone RTSS diff viewer scaffold

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .env
5
+ .DS_Store
6
+ .streamlit/
Makefile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PYTHON ?= python3
2
+ VENV ?= .venv
3
+ PIP := $(VENV)/bin/pip
4
+ STREAMLIT := $(VENV)/bin/streamlit
5
+
6
+ SPACE ?= amithjkamath/rtssdiffviewer
7
+ HF_REMOTE ?= hf
8
+ DEPLOY_BRANCH ?= deploy
9
+
10
+ .PHONY: install run fmt lint clean deploy-init deploy status
11
+
12
+ install:
13
+ $(PYTHON) -m venv $(VENV)
14
+ $(PIP) install --upgrade pip
15
+ $(PIP) install -r requirements.txt
16
+
17
+ run:
18
+ $(STREAMLIT) run app.py
19
+
20
+ fmt:
21
+ @echo "No formatter configured."
22
+
23
+ lint:
24
+ $(VENV)/bin/python -m py_compile app.py src/rtssdiffviewer/*.py
25
+
26
+ clean:
27
+ rm -rf $(VENV) __pycache__ src/rtssdiffviewer/__pycache__
28
+
29
+ deploy-init:
30
+ @if git remote | grep -q '^$(HF_REMOTE)$$'; then \
31
+ git remote set-url $(HF_REMOTE) https://huggingface.co/spaces/$(SPACE); \
32
+ else \
33
+ git remote add $(HF_REMOTE) https://huggingface.co/spaces/$(SPACE); \
34
+ fi
35
+ @git fetch $(HF_REMOTE) || true
36
+ @if git show-ref --verify --quiet refs/heads/$(DEPLOY_BRANCH); then \
37
+ echo "Deploy branch $(DEPLOY_BRANCH) already exists"; \
38
+ else \
39
+ git checkout -b $(DEPLOY_BRANCH); \
40
+ git checkout -; \
41
+ fi
42
+
43
+ deploy:
44
+ bash deploy.sh $(SPACE)
45
+
46
+ status:
47
+ @echo "Space URL: https://huggingface.co/spaces/$(SPACE)"
README.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: RTSS Diff Viewer
3
+ emoji: CT
4
+ colorFrom: blue
5
+ colorTo: cyan
6
+ sdk: streamlit
7
+ sdk_version: 1.55.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # RTSS Diff Viewer
13
+
14
+ A standalone Streamlit app for:
15
+
16
+ - converting RTSS `.dcm` files to JSON
17
+ - downloading normalized JSON outputs
18
+ - visualizing git-like diffs between two RTSS versions
19
+ - batch-uploading multiple variants and switching any two versions for comparison
20
+
21
+ ## Local Setup
22
+
23
+ ```bash
24
+ make install
25
+ make run
26
+ ```
27
+
28
+ ## Hugging Face Spaces Deployment
29
+
30
+ Use the deploy workflow in this repository:
31
+
32
+ ```bash
33
+ make deploy-init SPACE=amithjkamath/rtssdiffviewer
34
+ make deploy
35
+ ```
36
+
37
+ This pushes local `deploy` branch to Space `main`.
app.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Streamlit RTSS diff viewer app."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import streamlit as st
12
+
13
+ ROOT = Path(__file__).resolve().parent
14
+ SRC_DIR = ROOT / "src"
15
+ if str(SRC_DIR) not in sys.path:
16
+ sys.path.insert(0, str(SRC_DIR))
17
+
18
+ from rtssdiffviewer.dcm_to_json import dcm_to_json # noqa: E402
19
+ from rtssdiffviewer.diff_core import ( # noqa: E402
20
+ COMPONENT_KEYS,
21
+ DEFAULT_VOLATILE_TAG_PREFIXES,
22
+ normalize_value,
23
+ pretty_json_text,
24
+ select_component,
25
+ unified_diff_text,
26
+ )
27
+
28
+ try:
29
+ from st_diff_viewer import diff_viewer
30
+ except Exception:
31
+ diff_viewer = None
32
+
33
+
34
+ def dcm_bytes_to_json_dict(payload: bytes) -> dict[str, Any]:
35
+ with tempfile.NamedTemporaryFile(suffix=".dcm", delete=False) as tmp:
36
+ tmp.write(payload)
37
+ tmp_path = Path(tmp.name)
38
+ try:
39
+ return dcm_to_json(tmp_path)
40
+ finally:
41
+ tmp_path.unlink(missing_ok=True)
42
+
43
+
44
+ def ensure_state() -> None:
45
+ st.session_state.setdefault("left_name", None)
46
+ st.session_state.setdefault("right_name", None)
47
+ st.session_state.setdefault("left_json_raw", None)
48
+ st.session_state.setdefault("right_json_raw", None)
49
+ st.session_state.setdefault("batch_variants", {})
50
+
51
+
52
+ def render_diff_panel(
53
+ *,
54
+ left_name: str,
55
+ right_name: str,
56
+ left_raw: dict[str, Any],
57
+ right_raw: dict[str, Any],
58
+ component: str,
59
+ precision: int,
60
+ keep_volatile: bool,
61
+ key_prefix: str,
62
+ ) -> None:
63
+ ignore_prefixes: set[str] = set()
64
+ if not keep_volatile:
65
+ ignore_prefixes.update(DEFAULT_VOLATILE_TAG_PREFIXES)
66
+
67
+ left_selected = select_component(left_raw, component)
68
+ right_selected = select_component(right_raw, component)
69
+
70
+ left_norm = normalize_value(left_selected, precision, ignore_prefixes)
71
+ right_norm = normalize_value(right_selected, precision, ignore_prefixes)
72
+
73
+ left_text = pretty_json_text(left_norm)
74
+ right_text = pretty_json_text(right_norm)
75
+
76
+ col1, col2, col3 = st.columns(3)
77
+ with col1:
78
+ st.download_button(
79
+ "Download left JSON",
80
+ data=left_text,
81
+ file_name=f"{Path(left_name).stem}.{component}.json",
82
+ mime="application/json",
83
+ key=f"{key_prefix}_dl_left",
84
+ )
85
+ with col2:
86
+ st.download_button(
87
+ "Download right JSON",
88
+ data=right_text,
89
+ file_name=f"{Path(right_name).stem}.{component}.json",
90
+ mime="application/json",
91
+ key=f"{key_prefix}_dl_right",
92
+ )
93
+
94
+ diff_text = unified_diff_text(left_text, right_text, left_name, right_name)
95
+ with col3:
96
+ st.download_button(
97
+ "Download unified diff",
98
+ data=diff_text,
99
+ file_name=f"{Path(left_name).stem}__{Path(right_name).stem}.{component}.diff",
100
+ mime="text/plain",
101
+ key=f"{key_prefix}_dl_diff",
102
+ )
103
+
104
+ st.markdown("### Diff View")
105
+ if diff_viewer is not None:
106
+ diff_viewer(left_text, right_text, split_view=True)
107
+ else:
108
+ if diff_text.strip():
109
+ st.code(diff_text, language="diff")
110
+ else:
111
+ st.success("No differences after normalization and filtering.")
112
+
113
+
114
+ def main() -> None:
115
+ st.set_page_config(page_title="RTSS Diff Viewer", layout="wide")
116
+ ensure_state()
117
+
118
+ st.title("RTSS Diff Viewer")
119
+ st.caption("Upload RTSS .dcm files, convert to JSON, and compare with git-like diffs.")
120
+
121
+ with st.sidebar:
122
+ st.header("Diff Controls")
123
+ component_options = ["all", "metadata", *COMPONENT_KEYS.keys()]
124
+ component = st.selectbox("Component", options=component_options, index=0)
125
+ precision = st.slider("Float precision", min_value=2, max_value=10, value=6)
126
+ keep_volatile = st.checkbox("Keep volatile UID/time tags", value=False)
127
+
128
+ tab_upload, tab_diff, tab_batch = st.tabs(
129
+ ["1) Upload Pair", "2) Pair Diff", "3) Batch Compare"]
130
+ )
131
+
132
+ with tab_upload:
133
+ left_col, right_col = st.columns(2)
134
+ with left_col:
135
+ left_file = st.file_uploader("Left RTSS (.dcm)", type=["dcm"], key="left_pair")
136
+ with right_col:
137
+ right_file = st.file_uploader("Right RTSS (.dcm)", type=["dcm"], key="right_pair")
138
+
139
+ if st.button("Convert pair", type="primary"):
140
+ if left_file is None or right_file is None:
141
+ st.warning("Upload both files first.")
142
+ else:
143
+ with st.spinner("Converting files..."):
144
+ st.session_state.left_json_raw = dcm_bytes_to_json_dict(left_file.getvalue())
145
+ st.session_state.right_json_raw = dcm_bytes_to_json_dict(right_file.getvalue())
146
+ st.session_state.left_name = left_file.name
147
+ st.session_state.right_name = right_file.name
148
+ st.success("Pair conversion complete.")
149
+
150
+ with tab_diff:
151
+ left_raw = st.session_state.left_json_raw
152
+ right_raw = st.session_state.right_json_raw
153
+ if left_raw is None or right_raw is None:
154
+ st.info("Convert a pair in tab 1 first.")
155
+ else:
156
+ render_diff_panel(
157
+ left_name=st.session_state.left_name,
158
+ right_name=st.session_state.right_name,
159
+ left_raw=left_raw,
160
+ right_raw=right_raw,
161
+ component=component,
162
+ precision=precision,
163
+ keep_volatile=keep_volatile,
164
+ key_prefix="pair",
165
+ )
166
+
167
+ with tab_batch:
168
+ files = st.file_uploader(
169
+ "Upload RTSS variant set (.dcm)",
170
+ type=["dcm"],
171
+ accept_multiple_files=True,
172
+ key="batch_upload",
173
+ )
174
+
175
+ if st.button("Convert uploaded set", type="primary", key="batch_convert"):
176
+ if not files:
177
+ st.warning("Upload at least two RTSS files.")
178
+ else:
179
+ converted: dict[str, dict[str, Any]] = {}
180
+ with st.spinner("Converting variant set..."):
181
+ for idx, file in enumerate(files, start=1):
182
+ name = file.name
183
+ if name in converted:
184
+ name = f"{idx:02d}_{name}"
185
+ converted[name] = dcm_bytes_to_json_dict(file.getvalue())
186
+ st.session_state.batch_variants = converted
187
+ st.success(f"Converted {len(converted)} file(s).")
188
+
189
+ variants = st.session_state.batch_variants
190
+ if not variants:
191
+ st.info("No batch set loaded yet.")
192
+ return
193
+
194
+ names = sorted(variants.keys())
195
+ if len(names) < 2:
196
+ st.warning("Need at least two variants.")
197
+ return
198
+
199
+ left_col, right_col = st.columns(2)
200
+ with left_col:
201
+ left_name = st.selectbox("Left variant", names, index=0, key="batch_left")
202
+ with right_col:
203
+ right_name = st.selectbox("Right variant", names, index=1, key="batch_right")
204
+
205
+ if left_name == right_name:
206
+ st.warning("Select two different variants.")
207
+ else:
208
+ render_diff_panel(
209
+ left_name=left_name,
210
+ right_name=right_name,
211
+ left_raw=variants[left_name],
212
+ right_raw=variants[right_name],
213
+ component=component,
214
+ precision=precision,
215
+ keep_volatile=keep_volatile,
216
+ key_prefix="batch",
217
+ )
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
deploy.sh ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SPACE_NAME="${1:-}"
5
+ if [[ -z "$SPACE_NAME" ]]; then
6
+ echo "Usage: bash deploy.sh <username/space-name>"
7
+ exit 1
8
+ fi
9
+
10
+ if [[ ! -d .git ]]; then
11
+ echo "Error: run from a git repository"
12
+ exit 1
13
+ fi
14
+
15
+ HF_REMOTE="hf"
16
+ DEPLOY_BRANCH="deploy"
17
+ SPACE_URL="https://huggingface.co/spaces/${SPACE_NAME}"
18
+
19
+ if git remote | grep -q "^${HF_REMOTE}$"; then
20
+ git remote set-url "${HF_REMOTE}" "${SPACE_URL}"
21
+ else
22
+ git remote add "${HF_REMOTE}" "${SPACE_URL}"
23
+ fi
24
+
25
+ if ! git show-ref --verify --quiet "refs/heads/${DEPLOY_BRANCH}"; then
26
+ git checkout -b "${DEPLOY_BRANCH}"
27
+ fi
28
+
29
+ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
30
+
31
+ if [[ "$CURRENT_BRANCH" != "$DEPLOY_BRANCH" ]]; then
32
+ git checkout "$DEPLOY_BRANCH"
33
+ fi
34
+
35
+ git add .
36
+ if ! git diff --staged --quiet; then
37
+ git commit -m "Deploy RTSS diff viewer"
38
+ fi
39
+
40
+ git push "${HF_REMOTE}" "${DEPLOY_BRANCH}:main"
41
+
42
+ echo "Deployment complete: ${SPACE_URL}"
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "rtssdiffviewer"
3
+ version = "0.1.0"
4
+ description = "Streamlit app for RTSS DICOM to JSON conversion and git-like diffing"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "streamlit>=1.43",
8
+ "st-diff-viewer>=1.0.7",
9
+ "pydicom>=3.0.1",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["setuptools>=68", "wheel"]
14
+ build-backend = "setuptools.build_meta"
15
+
16
+ [tool.setuptools]
17
+ package-dir = {"" = "src"}
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ streamlit>=1.43
2
+ st-diff-viewer>=1.0.7
3
+ pydicom>=3.0.1
src/rtssdiffviewer/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """rtssdiffviewer package."""
2
+
3
+ from .dcm_to_json import dcm_to_json
4
+
5
+ __all__ = ["dcm_to_json"]
src/rtssdiffviewer/dcm_to_json.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DICOM RTSS to JSON conversion helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pydicom
11
+ from pydicom.dataelem import DataElement
12
+ from pydicom.dataset import Dataset
13
+ from pydicom.uid import UID
14
+
15
+
16
+ def _tag_key(elem: DataElement) -> str:
17
+ tag_str = f"({elem.tag.group:04X},{elem.tag.element:04X})"
18
+ keyword = elem.keyword or ""
19
+ return f"{tag_str} {keyword}".strip()
20
+
21
+
22
+ def _bytes_summary(data: bytes) -> dict[str, Any]:
23
+ return {
24
+ "_type": "bytes",
25
+ "length": len(data),
26
+ "sha256": hashlib.sha256(data).hexdigest(),
27
+ }
28
+
29
+
30
+ def _scalar(value: Any) -> Any:
31
+ if value is None:
32
+ return None
33
+ if isinstance(value, (int, float, bool)):
34
+ return value
35
+ if isinstance(value, bytes):
36
+ return _bytes_summary(value)
37
+ return str(value)
38
+
39
+
40
+ def _convert_value(elem: DataElement) -> Any:
41
+ vr = elem.VR
42
+ if vr == "SQ":
43
+ return [_dataset_to_dict(item) for item in (elem.value or [])]
44
+
45
+ if vr in ("OB", "OW", "OD", "OF", "OL", "OV", "UN"):
46
+ raw = elem.value
47
+ if isinstance(raw, bytes):
48
+ return _bytes_summary(raw)
49
+ return {"_type": "bytes", "length": 0, "sha256": ""}
50
+
51
+ if vr == "DS":
52
+ raw = elem.value
53
+ if raw is None:
54
+ return None
55
+ if hasattr(raw, "__iter__") and not isinstance(raw, str):
56
+ try:
57
+ return [float(v) for v in raw]
58
+ except (TypeError, ValueError):
59
+ return [str(v) for v in raw]
60
+ try:
61
+ return float(raw)
62
+ except (TypeError, ValueError):
63
+ return str(raw)
64
+
65
+ if vr == "IS":
66
+ raw = elem.value
67
+ if raw is None:
68
+ return None
69
+ if hasattr(raw, "__iter__") and not isinstance(raw, str):
70
+ try:
71
+ return [int(v) for v in raw]
72
+ except (TypeError, ValueError):
73
+ return [str(v) for v in raw]
74
+ try:
75
+ return int(raw)
76
+ except (TypeError, ValueError):
77
+ return str(raw)
78
+
79
+ raw = elem.value
80
+ if hasattr(raw, "__iter__") and not isinstance(raw, (str, bytes, UID)):
81
+ items = list(raw)
82
+ if len(items) == 1:
83
+ return _scalar(items[0])
84
+ return [_scalar(v) for v in items]
85
+
86
+ return _scalar(raw)
87
+
88
+
89
+ def _dataset_to_dict(ds: Dataset) -> dict[str, Any]:
90
+ out: dict[str, Any] = {}
91
+ for elem in ds:
92
+ key = _tag_key(elem)
93
+ if elem.keyword == "ContourData" and elem.value:
94
+ flat = [float(v) for v in elem.value]
95
+ out[key] = [flat[i : i + 3] for i in range(0, len(flat), 3)]
96
+ else:
97
+ out[key] = _convert_value(elem)
98
+ return out
99
+
100
+
101
+ def dcm_to_json(dcm_path: str | Path, output_path: str | Path | None = None) -> dict[str, Any]:
102
+ """Convert DICOM file to a deterministic JSON dict and optionally write it."""
103
+ dcm_path = Path(dcm_path)
104
+ ds = pydicom.dcmread(str(dcm_path), force=True)
105
+
106
+ result: dict[str, Any] = {}
107
+ if hasattr(ds, "file_meta") and ds.file_meta:
108
+ result["_file_meta"] = _dataset_to_dict(ds.file_meta)
109
+ result.update(_dataset_to_dict(ds))
110
+
111
+ if output_path is not None:
112
+ output_path = Path(output_path)
113
+ output_path.parent.mkdir(parents=True, exist_ok=True)
114
+ with output_path.open("w", encoding="utf-8") as fh:
115
+ json.dump(result, fh, indent=2, ensure_ascii=False, sort_keys=False)
116
+
117
+ return result
src/rtssdiffviewer/diff_core.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core RTSS JSON normalization and diff helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ from typing import Any
8
+
9
+ DEFAULT_VOLATILE_TAG_PREFIXES = {
10
+ "(0008,0012)",
11
+ "(0008,0013)",
12
+ "(0008,0018)",
13
+ "(0020,000D)",
14
+ "(0020,000E)",
15
+ "(0020,0052)",
16
+ "(3006,0008)",
17
+ "(3006,0009)",
18
+ }
19
+
20
+ COMPONENT_KEYS = {
21
+ "references": ["(3006,0010) ReferencedFrameOfReferenceSequence"],
22
+ "structures": [
23
+ "(3006,0020) StructureSetROISequence",
24
+ "(3006,0080) RTROIObservationsSequence",
25
+ ],
26
+ "contours": ["(3006,0039) ROIContourSequence"],
27
+ }
28
+
29
+
30
+ def is_volatile_tag(key: str, ignore_prefixes: set[str]) -> bool:
31
+ if not key.startswith("("):
32
+ return False
33
+ tag_prefix = key.split(" ", 1)[0]
34
+ return tag_prefix in ignore_prefixes
35
+
36
+
37
+ def normalize_value(value: Any, precision: int, ignore_prefixes: set[str]) -> Any:
38
+ if isinstance(value, dict):
39
+ out: dict[str, Any] = {}
40
+ for key in sorted(value.keys()):
41
+ if is_volatile_tag(key, ignore_prefixes):
42
+ continue
43
+ out[key] = normalize_value(value[key], precision, ignore_prefixes)
44
+ return out
45
+
46
+ if isinstance(value, list):
47
+ return [normalize_value(v, precision, ignore_prefixes) for v in value]
48
+
49
+ if isinstance(value, float):
50
+ return round(value, precision)
51
+
52
+ return value
53
+
54
+
55
+ def select_component(data: dict[str, Any], component: str) -> dict[str, Any]:
56
+ if component == "all":
57
+ return data
58
+
59
+ if component == "metadata":
60
+ excluded: set[str] = set()
61
+ for keys in COMPONENT_KEYS.values():
62
+ excluded.update(keys)
63
+ return {k: v for k, v in data.items() if k not in excluded}
64
+
65
+ keys = COMPONENT_KEYS[component]
66
+ return {k: data[k] for k in keys if k in data}
67
+
68
+
69
+ def pretty_json_text(data: dict[str, Any]) -> str:
70
+ return json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
71
+
72
+
73
+ def unified_diff_text(left_text: str, right_text: str, left_name: str, right_name: str) -> str:
74
+ left_lines = [line + "\n" for line in left_text.splitlines()]
75
+ right_lines = [line + "\n" for line in right_text.splitlines()]
76
+ diff = difflib.unified_diff(
77
+ left_lines,
78
+ right_lines,
79
+ fromfile=left_name,
80
+ tofile=right_name,
81
+ lineterm="",
82
+ )
83
+ return "\n".join(diff)