Skills › Scientific Agent Skills › pydicom › anonymize_dicom.py
anonymize_dicom.py
718 lines · skills/pydicom/scripts/anonymize_dicom.py at 5f2bc03
1#!/usr/bin/env python3
2"""Create a bounded, pseudonymized DICOM derivative without compliance claims.
4This local-only helper preserves the source file and writes a new output. DICOM
5metadata, file names, private elements, overlays, structured content, and pixel
6data may contain PHI. The starter profile is deliberately incomplete: select a
7profile for the intended context and obtain privacy/DICOM expert verification.
8"""
10from __future__ import annotations
12import argparse
13import os
14import secrets
15import stat
16import sys
17from collections import Counter
18from collections.abc import Sequence as SequenceValue
19from datetime import date, timedelta
20from pathlib import Path
21from typing import Any
23from _common import (
24 DEFAULT_MAX_ELEMENTS,
25 DEFAULT_MAX_INPUT_BYTES,
26 DEFAULT_MAX_OUTPUT_BYTES,
27 HARD_MAX_ELEMENTS,
28 HARD_MAX_INPUT_BYTES,
29 HARD_MAX_OUTPUT_BYTES,
30 MAX_SEQUENCE_DEPTH,
31 SCHEMA_VERSION,
32 STRUCTURAL_UID_KEYWORDS,
33 TEXT_VRS,
34 UID_REMAP_KEYWORDS,
35 ToolError,
36 atomic_generated_file,
37 atomic_write,
38 bounded_int,
39 checked_input,
40 checked_output,
41 counter_dict,
42 derive_token,
43 derive_uid,
44 element_tag,
45 emit_json,
46 fail_json,
47 json_bytes,
48 load_json,
49 parse_size,
50 paths_overlap,
51 require_pydicom,
52 require_text,
53 safe_dcmread,
54 sha256_text,
55 starter_profile,
56 validate_profile,
57)
59TOOL = "anonymize_dicom"
60PIXEL_KEYWORDS = {"DoubleFloatPixelData", "FloatPixelData", "PixelData"}
63def _load_key(path: Path) -> bytes:
64 info = path.stat()
65 if os.name != "nt":
66 if stat.S_IMODE(info.st_mode) & 0o077:
67 raise ToolError("UID key permissions must deny group and other access")
68 if hasattr(os, "getuid") and info.st_uid != os.getuid():
69 raise ToolError("UID key must be owned by the current user")
70 payload = path.read_bytes()
71 if len(payload) == 64:
72 try:
73 payload = bytes.fromhex(payload.decode("ascii"))
74 except (UnicodeError, ValueError):
75 pass
76 if len(payload) < 32:
77 raise ToolError("UID key must contain at least 32 bytes of secret material")
78 return payload[:128]
81def _map_scalar_uid(
82 value: Any, *, key: bytes, scope: str, mapping: dict[str, str]
83) -> str:
84 original = str(value)
85 replacement = mapping.get(original)
86 if replacement is None:
87 replacement = derive_uid(original, key=key, scope=scope)
88 mapping[original] = replacement
89 return replacement
92def _map_uid_value(
93 value: Any, *, key: bytes, scope: str, mapping: dict[str, str]
94) -> Any:
95 if isinstance(value, SequenceValue) and not isinstance(value, (str, bytes)):
96 return [
97 _map_scalar_uid(item, key=key, scope=scope, mapping=mapping)
98 for item in value
99 ]
100 return _map_scalar_uid(value, key=key, scope=scope, mapping=mapping)
103def _pseudonym_value(value: Any, *, keyword: str, key: bytes, scope: str) -> str:
104 token = derive_token(str(value), key=key, scope=scope)
105 if keyword == "PatientName":
106 return f"PSEUDONYM^{token}"
107 return f"P-{token}"
110def _shift_date_text(value: Any, days: int) -> tuple[str, bool]:
111 text = str(value)
112 if len(text) != 8 or not text.isdigit():
113 return "", False
114 try:
115 shifted = date(int(text[:4]), int(text[4:6]), int(text[6:8])) + timedelta(
116 days=days
117 )
118 except (ValueError, OverflowError):
119 return "", False
120 return shifted.strftime("%Y%m%d"), True
123def _shift_datetime_text(value: Any, days: int) -> tuple[str, bool]:
124 text = str(value)
125 if len(text) < 8 or not text[:8].isdigit():
126 return "", False
127 shifted, ok = _shift_date_text(text[:8], days)
128 return (shifted + text[8:], True) if ok else ("", False)
131def _date_action(
132 element: Any,
133 *,
134 date_policy: str,
135 date_shift_days: int | None,
136 retain_times: bool,
137 counters: Counter[str],
138) -> None:
139 vr = str(element.VR)
140 if date_policy == "keep":
141 counters["dates_kept"] += 1
142 return
143 if date_policy == "empty":
144 element.value = ""
145 counters["dates_or_times_emptied"] += 1
146 return
147 if date_shift_days is None:
148 raise ToolError("date shift policy requires --date-shift-days")
149 if vr == "TM":
150 if retain_times:
151 counters["standalone_times_retained"] += 1
152 else:
153 element.value = ""
154 counters["standalone_times_emptied"] += 1
155 return
156 if isinstance(element.value, SequenceValue) and not isinstance(
157 element.value, (str, bytes)
158 ):
159 values = list(element.value)
160 else:
161 values = [element.value]
162 shifted_values: list[str] = []
163 for value in values:
164 if vr == "DA":
165 shifted, ok = _shift_date_text(value, date_shift_days)
166 else:
167 shifted, ok = _shift_datetime_text(value, date_shift_days)
168 shifted_values.append(shifted)
169 counters["dates_shifted" if ok else "unshiftable_dates_emptied"] += 1
170 element.value = shifted_values if len(shifted_values) > 1 else shifted_values[0]
173def _looks_instance_uid(keyword: str) -> bool:
174 return keyword in UID_REMAP_KEYWORDS or keyword.endswith(
175 ("InstanceUID", "FrameOfReferenceUID")
176 )
179def transform_dataset(
180 dataset: Any,
181 *,
182 profile: dict[str, Any],
183 key: bytes,
184 scope: str,
185 date_shift_days: int | None,
186 retain_times: bool,
187 allow_private_retention: bool,
188 allow_date_retention: bool,
189 max_elements: int,
190) -> tuple[dict[str, Any], dict[str, str]]:
191 """Apply the bounded starter/profile actions in place."""
193 actions = profile["actions"]
194 private_policy = profile["private_policy"]
195 date_policy = profile["date_policy"]
196 if private_policy == "keep" and not allow_private_retention:
197 raise ToolError(
198 "private retention requires --allow-private-retention and expert review"
199 )
200 if date_policy == "keep" and not allow_date_retention:
201 raise ToolError("date retention requires --allow-date-retention")
202 if date_policy == "shift" and date_shift_days is None:
203 raise ToolError("date shift policy requires --date-shift-days")
204 if date_policy != "shift" and date_shift_days is not None:
205 raise ToolError("--date-shift-days is only valid with date policy shift")
206 if retain_times and date_policy != "shift":
207 raise ToolError("--retain-times is only valid with date policy shift")
209 counters: Counter[str] = Counter()
210 unresolved_text: Counter[str] = Counter()
211 unresolved_uids: Counter[str] = Counter()
212 uid_mapping: dict[str, str] = {}
213 stack: list[tuple[Any, int]] = [(dataset, 0)]
214 seen_elements = 0
215 pixel_present = False
216 burned_in_status = str(dataset.get("BurnedInAnnotation", "")).upper()
217 recognizable_status = str(dataset.get("RecognizableVisualFeatures", "")).upper()
219 while stack:
220 current, depth = stack.pop()
221 if depth > MAX_SEQUENCE_DEPTH:
222 raise ToolError("sequence nesting exceeds the hard depth limit")
223 for element in list(current):
224 seen_elements += 1
225 if seen_elements > max_elements:
226 raise ToolError("data-element limit exceeded")
227 keyword = element.keyword or element_tag(element)
228 group = int(element.tag.group)
230 if keyword in PIXEL_KEYWORDS:
231 pixel_present = True
232 counters["pixel_elements_preserved"] += 1
233 continue
235 if element.tag.is_private:
236 counters["private_elements_seen"] += 1
237 if private_policy == "reject":
238 raise ToolError("private elements present under reject policy")
239 if private_policy == "remove":
240 del current[element.tag]
241 counters["private_elements_removed"] += 1
242 continue
243 counters["private_elements_retained"] += 1
245 if 0x5000 <= group <= 0x50FF or 0x6000 <= group <= 0x60FF:
246 del current[element.tag]
247 counters["overlay_or_curve_elements_removed"] += 1
248 continue
250 action = actions.get(keyword, actions.get(element_tag(element)))
251 if action == "remove":
252 del current[element.tag]
253 counters["elements_removed"] += 1
254 continue
255 if action == "empty":
256 element.value = [] if str(element.VR) == "SQ" else ""
257 counters["elements_emptied"] += 1
258 continue
259 if action == "pseudonym":
260 if str(element.VR) not in TEXT_VRS or str(element.VR) == "UI":
261 raise ToolError(
262 "pseudonym action is only valid for non-UID text elements"
263 )
264 element.value = _pseudonym_value(
265 element.value,
266 keyword=keyword,
267 key=key,
268 scope=scope,
269 )
270 counters["elements_pseudonymized"] += 1
271 continue
272 if action == "uid":
273 if str(element.VR) != "UI":
274 raise ToolError("uid action is only valid for UI elements")
275 if keyword in STRUCTURAL_UID_KEYWORDS:
276 raise ToolError("structural UIDs must not be remapped")
277 element.value = _map_uid_value(
278 element.value,
279 key=key,
280 scope=scope,
281 mapping=uid_mapping,
282 )
283 counters["uid_elements_remapped"] += 1
284 continue
285 if action == "keep":
286 counters["profile_keep_actions"] += 1
287 # Keeping a sequence container must not exempt its children
288 # from their own privacy actions.
289 if str(element.VR) != "SQ":
290 continue
292 if str(element.VR) == "SQ":
293 for item in reversed(list(element.value)):
294 stack.append((item, depth + 1))
295 continue
297 if str(element.VR) in {"DA", "DT", "TM"}:
298 _date_action(
299 element,
300 date_policy=date_policy,
301 date_shift_days=date_shift_days,
302 retain_times=retain_times,
303 counters=counters,
304 )
305 continue
307 if str(element.VR) == "UI":
308 if keyword in STRUCTURAL_UID_KEYWORDS:
309 counters["structural_uid_elements_preserved"] += 1
310 elif _looks_instance_uid(keyword):
311 element.value = _map_uid_value(
312 element.value,
313 key=key,
314 scope=scope,
315 mapping=uid_mapping,
316 )
317 counters["uid_elements_remapped"] += 1
318 else:
319 unresolved_uids[keyword] += 1
320 continue
322 if str(element.VR) in TEXT_VRS and action != "keep":
323 unresolved_text[keyword] += 1
325 # Never assert successful de-identification. These attributes are removed by
326 # the starter profile and then set conservatively after processing.
327 dataset.PatientIdentityRemoved = "NO"
328 if "DeidentificationMethod" in dataset:
329 del dataset.DeidentificationMethod
330 if "DeidentificationMethodCodeSequence" in dataset:
331 del dataset.DeidentificationMethodCodeSequence
333 report = {
334 "actions": counter_dict(counters),
335 "burned_in_annotation": burned_in_status or "ABSENT",
336 "date_policy": date_policy,
337 "elements_examined": seen_elements,
338 "expert_verification_required": True,
339 "not_a_compliance_claim": True,
340 "patient_identity_removed": "NO",
341 "pixel_data_preserved": pixel_present,
342 "private_policy": private_policy,
343 "recognizable_visual_features": recognizable_status or "ABSENT",
344 "residual_text_elements_by_keyword": counter_dict(unresolved_text),
345 "unclassified_uid_elements_by_keyword": counter_dict(unresolved_uids),
346 "uid_mappings": len(uid_mapping),
347 }
348 return report, uid_mapping
351def _rebuild_file_meta(dataset: Any, *, original_transfer_syntax: Any) -> None:
352 require_pydicom()
353 from pydicom.dataset import FileMetaDataset
354 from pydicom.uid import PYDICOM_IMPLEMENTATION_UID
356 if not dataset.get("SOPClassUID") or not dataset.get("SOPInstanceUID"):
357 raise ToolError("SOP Class and SOP Instance UIDs are required for safe output")
358 file_meta = FileMetaDataset()
359 file_meta.FileMetaInformationVersion = b"\x00\x01"
360 file_meta.MediaStorageSOPClassUID = dataset.SOPClassUID
361 file_meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID
362 file_meta.TransferSyntaxUID = original_transfer_syntax
363 file_meta.ImplementationClassUID = PYDICOM_IMPLEMENTATION_UID
364 dataset.file_meta = file_meta
365 dataset.preamble = b"\x00" * 128
368def process_file(args: argparse.Namespace) -> dict[str, Any]:
369 max_input_bytes = parse_size(
370 args.max_input_bytes,
371 name="max_input_bytes",
372 maximum=HARD_MAX_INPUT_BYTES,
373 )
374 max_output_bytes = parse_size(
375 args.max_output_bytes,
376 name="max_output_bytes",
377 maximum=HARD_MAX_OUTPUT_BYTES,
378 )
379 max_elements = bounded_int(
380 args.max_elements,
381 name="max_elements",
382 minimum=1,
383 maximum=HARD_MAX_ELEMENTS,
384 )
385 source = checked_input(
386 args.input,
387 root=args.root,
388 kind="file",
389 max_bytes=max_input_bytes,
390 )
391 destination = checked_output(args.output, root=args.root, force=args.force)
392 audit_path = checked_output(args.audit_report, root=args.root, force=args.force)
393 key_path = checked_input(
394 args.uid_key_file,
395 root=args.root,
396 kind="file",
397 max_bytes=4 * 1024,
398 )
399 for candidate in (destination, audit_path):
400 if paths_overlap(source, candidate):
401 raise ToolError("input and output paths must be distinct")
402 if paths_overlap(key_path, candidate):
403 raise ToolError("UID key and output paths must be distinct")
404 if paths_overlap(source, key_path):
405 raise ToolError("DICOM input and UID key must be distinct")
406 if destination == audit_path:
407 raise ToolError("DICOM output and audit report must be distinct")
409 mapping_path: Path | None = None
410 if args.uid_map_output:
411 if not args.acknowledge_sensitive_map:
412 raise ToolError("UID map output requires --acknowledge-sensitive-map")
413 mapping_path = checked_output(
414 args.uid_map_output, root=args.root, force=args.force
415 )
416 if mapping_path in {destination, audit_path} or paths_overlap(
417 source, mapping_path
418 ):
419 raise ToolError("UID map path must be distinct from all other paths")
420 if paths_overlap(key_path, mapping_path):
421 raise ToolError("UID map must not overwrite the UID key")
423 scope = require_text(args.uid_scope, name="uid_scope", maximum=128)
424 key = _load_key(key_path)
425 profile = starter_profile()
426 profile_source = "built-in"
427 if args.profile_json:
428 profile_path = checked_input(
429 args.profile_json,
430 root=args.root,
431 kind="file",
432 max_bytes=1 * 1024 * 1024,
433 )
434 if any(
435 paths_overlap(profile_path, candidate)
436 for candidate in (source, destination, audit_path, key_path)
437 ):
438 raise ToolError("action profile must use a distinct local file")
439 if mapping_path is not None and paths_overlap(profile_path, mapping_path):
440 raise ToolError("action profile and UID map paths must be distinct")
441 profile = validate_profile(load_json(profile_path))
442 profile_source = "local-json"
443 if args.private_policy:
444 profile["private_policy"] = args.private_policy
445 if args.date_policy:
446 profile["date_policy"] = args.date_policy
447 if args.date_shift_days is not None:
448 bounded_int(
449 args.date_shift_days,
450 name="date_shift_days",
451 minimum=-365_000,
452 maximum=365_000,
453 )
455 dataset = safe_dcmread(
456 source,
457 stop_before_pixels=False,
458 force=False,
459 defer_size=args.defer_size,
460 )
461 pydicom = require_pydicom()
462 original_transfer_syntax = dataset.file_meta.get("TransferSyntaxUID")
463 if original_transfer_syntax is None:
464 raise ToolError("Transfer Syntax UID is required for safe output")
466 transform_report, uid_mapping = transform_dataset(
467 dataset,
468 profile=profile,
469 key=key,
470 scope=scope,
471 date_shift_days=args.date_shift_days,
472 retain_times=args.retain_times,
473 allow_private_retention=args.allow_private_retention,
474 allow_date_retention=args.allow_date_retention,
475 max_elements=max_elements,
476 )
477 _rebuild_file_meta(dataset, original_transfer_syntax=original_transfer_syntax)
479 def writer(path: Path) -> None:
480 pydicom.dcmwrite(
481 path,
482 dataset,
483 enforce_file_format=True,
484 overwrite=True,
485 )
487 def validator(path: Path) -> None:
488 check = safe_dcmread(path, stop_before_pixels=True, force=False)
489 if str(check.get("PatientIdentityRemoved", "")) != "NO":
490 raise ToolError("output verification failed")
491 if str(check.file_meta.get("MediaStorageSOPInstanceUID", "")) != str(
492 check.get("SOPInstanceUID", "")
493 ):
494 raise ToolError("output file meta verification failed")
496 atomic_generated_file(
497 destination,
498 writer=writer,
499 validator=validator,
500 force=args.force,
501 max_bytes=max_output_bytes,
502 )
504 warnings = [
505 "This output has not been established as de-identified or compliant.",
506 "DICOM metadata and pixels may still contain PHI; expert review is required.",
507 "Date/time actions can affect validity and longitudinal utility.",
508 ]
509 if transform_report["pixel_data_preserved"]:
510 warnings.append(
511 "Pixel data was not inspected or cleaned for burned-in annotations "
512 "or recognizable visual features."
513 )
514 if transform_report["burned_in_annotation"] != "NO":
515 warnings.append("Burned In Annotation is absent, unknown, or not NO.")
516 if transform_report["unclassified_uid_elements_by_keyword"]:
517 warnings.append("One or more UI elements were not classified for remapping.")
518 if transform_report["residual_text_elements_by_keyword"]:
519 warnings.append("One or more textual elements remain for profile review.")
520 if profile["private_policy"] == "keep":
521 warnings.append("Private elements were retained by explicit high-risk policy.")
522 if profile["date_policy"] == "keep":
523 warnings.append("Dates/times were retained by explicit high-risk policy.")
525 audit = {
526 "input_files": 1,
527 "network_accessed": False,
528 "ok": True,
529 "original_preserved": True,
530 "output_files": 1,
531 "profile": {
532 "name": profile["name"],
533 "source": profile_source,
534 "version": profile["version"],
535 },
536 "schema_version": SCHEMA_VERSION,
537 "scope_sha256": sha256_text(scope),
538 "tool": TOOL,
539 "transform": transform_report,
540 "uid_key_sha256": sha256_text(key.hex()),
541 "uid_map_written": mapping_path is not None,
542 "warnings": warnings,
543 }
544 atomic_write(audit_path, json_bytes(audit), force=args.force)
545 if mapping_path is not None:
546 mapping_document = {
547 "entries": [
548 {"original": original, "replacement": replacement}
549 for original, replacement in sorted(uid_mapping.items())
550 ],
551 "schema_version": SCHEMA_VERSION,
552 "scope_sha256": sha256_text(scope),
553 "sensitive": True,
554 "tool": TOOL,
555 }
556 atomic_write(mapping_path, json_bytes(mapping_document), force=args.force)
557 return audit
560def build_parser() -> argparse.ArgumentParser:
561 parser = argparse.ArgumentParser(
562 description=(
563 "Create a bounded pseudonymized DICOM derivative. This is not a "
564 "DICOM PS3.15, HIPAA, GDPR, or other compliance claim."
565 ),
566 formatter_class=argparse.RawDescriptionHelpFormatter,
567 epilog="""
568Local authorized data only. Originals are never modified. Metadata, private
569elements, file names, overlays, structured content, and pixels may contain PHI.
570The output requires profile-specific privacy and DICOM expert verification.
571The UID key and optional map are re-identification secrets: keep them separate
572from derivatives in approved encrypted/managed storage and never commit them.
574Examples:
575 python anonymize_dicom.py --generate-uid-key project.key
576 python anonymize_dicom.py in.dcm out.dcm --uid-key-file project.key \\
577 --uid-scope study-export-v1 --audit-report out.audit.json
578 python anonymize_dicom.py in.dcm out.dcm --uid-key-file project.key \\
579 --uid-scope study-export-v1 --audit-report out.audit.json \\
580 --date-policy shift --date-shift-days 180
581""",
582 )
583 parser.add_argument("input", nargs="?", help="Local source DICOM file")
584 parser.add_argument("output", nargs="?", help="New local DICOM output file")
585 parser.add_argument(
586 "--root",
587 default=".",
588 help="Existing local root containing every input and output (default: .)",
589 )
590 parser.add_argument(
591 "--generate-uid-key",
592 metavar="PATH",
593 help="Create a new private 32-byte key and exit; refuses overwrite",
594 )
595 parser.add_argument(
596 "--uid-key-file",
597 help="Local secret key used for deterministic scoped pseudonyms and UIDs",
598 )
599 parser.add_argument(
600 "--uid-scope",
601 help="Non-PHI scope label; the same key/scope gives consistent mappings",
602 )
603 parser.add_argument(
604 "--profile-json",
605 help="Optional bounded local JSON action profile merged over starter actions",
606 )
607 parser.add_argument(
608 "--private-policy",
609 choices=("remove", "reject", "keep"),
610 help="Override profile private-element policy",
611 )
612 parser.add_argument(
613 "--date-policy",
614 choices=("empty", "shift", "keep"),
615 help="Override profile DA/DT/TM policy",
616 )
617 parser.add_argument(
618 "--date-shift-days",
619 type=int,
620 help="Fixed date shift for all DA/DT values; partial/invalid values are emptied",
621 )
622 parser.add_argument(
623 "--retain-times",
624 action="store_true",
625 help="With date shifting, retain standalone TM values (explicit risk choice)",
626 )
627 parser.add_argument(
628 "--allow-private-retention",
629 action="store_true",
630 help="Acknowledge that retained private elements may contain PHI",
631 )
632 parser.add_argument(
633 "--allow-date-retention",
634 action="store_true",
635 help="Acknowledge that retained dates/times may be identifying",
636 )
637 parser.add_argument(
638 "--audit-report",
639 help="Required private JSON audit report path; contains no element values",
640 )
641 parser.add_argument(
642 "--uid-map-output",
643 help="Optional private JSON mapping; contains sensitive original UIDs",
644 )
645 parser.add_argument(
646 "--acknowledge-sensitive-map",
647 action="store_true",
648 help="Required to write a UID map containing original identifiers",
649 )
650 parser.add_argument(
651 "--defer-size",
652 default="1 MiB",
653 help="pydicom deferred-value threshold (default: 1 MiB)",
654 )
655 parser.add_argument(
656 "--max-input-bytes",
657 default=str(DEFAULT_MAX_INPUT_BYTES),
658 help="Maximum source file size (integer or B/KiB/MiB/GiB)",
659 )
660 parser.add_argument(
661 "--max-output-bytes",
662 default=str(DEFAULT_MAX_OUTPUT_BYTES),
663 help="Maximum generated DICOM size (integer or B/KiB/MiB/GiB)",
664 )
665 parser.add_argument(
666 "--max-elements",
667 type=int,
668 default=DEFAULT_MAX_ELEMENTS,
669 help=f"Maximum recursive data elements (default: {DEFAULT_MAX_ELEMENTS})",
670 )
671 parser.add_argument(
672 "--force",
673 action="store_true",
674 help="Overwrite existing outputs, never the input",
675 )
676 return parser
679def main(argv: list[str] | None = None) -> int:
680 parser = build_parser()
681 args = parser.parse_args(argv)
682 try:
683 if args.generate_uid_key:
684 if args.input or args.output:
685 raise ToolError("key generation does not accept input/output files")
686 destination = checked_output(
687 args.generate_uid_key, root=args.root, force=False
688 )
689 atomic_write(destination, secrets.token_bytes(32), force=False)
690 emit_json(
691 {
692 "key_bytes": 32,
693 "network_accessed": False,
694 "ok": True,
695 "permissions": "0600",
696 "tool": TOOL,
697 }
698 )
699 return 0
700 required = {
701 "input": args.input,
702 "output": args.output,
703 "uid_key_file": args.uid_key_file,
704 "uid_scope": args.uid_scope,
705 "audit_report": args.audit_report,
706 }
707 missing = [name for name, value in required.items() if not value]
708 if missing:
709 raise ToolError(f"missing required arguments: {', '.join(missing)}")
710 audit = process_file(args)
711 emit_json(audit)
712 return 0
713 except Exception as exc: # noqa: BLE001 - sanitize unexpected library errors
714 return fail_json(TOOL, exc)
717if __name__ == "__main__":
718 sys.exit(main())