Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
*.test
*.out
testdata/dcm/*.dcm

# The copy of PS3.6 that audit_uid_dict.py downloads to compare the generated UID
# dictionary against the standard. ~10 MB, and reproducible from the URL in that
# script.
part06.xml
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Six Storage SOP Class UIDs that PS3.6 Table A-1 registers but pydicom's
`_uid_dict.py` does not carry, so `uid.Lookup` and `UID.Name` resolve them
instead of returning the raw UID string: `CTImageStorageForProcessing`
(`1.2.840.10008.5.1.4.1.1.2.3`), `EnhancedCTImageStorageForProcessing` (`.2.4`),
`LegacyConvertedEnhancedCTImageStorageForProcessing` (`.2.5`),
`WaveformPresentationStateStorage` (`1.2.840.10008.5.1.4.1.1.9.100.1`),
`WaveformAcquisitionPresentationStateStorage` (`.100.2`) and
`UltrasoundWaveformStorage` (`1.2.840.10008.5.1.4.1.1.601.5`). The UID
dictionary is otherwise a 1:1 mirror of pydicom's and stays one; these are
supplied from a `STANDARD_ADDITIONS` table in `generate_uid_dict.py`, which
refuses to run once pydicom defines any of them so the two cannot silently
drift. Reported in [#77](https://github.com/godicom-dev/godicom/issues/77),
which named the two Waveform Presentation State classes; a full diff of the
registry found the other four
- `audit_uid_dict.py` compares the generated dictionary against PS3.6 Tables A-1
and A-2 — it fetches the standard, then reports UIDs missing from the
dictionary, UIDs the standard does not register, and any keyword or retired-flag
disagreement. It is how the six above were found, and it is deliberately not in
CI: it depends on the network and on a document that changes independently of
this repository, so a new DICOM edition would otherwise turn an unrelated pull
request red. Run it after bumping the pydicom submodule

## [0.29.0] - 2026-08-25

### Fixed
Expand Down
155 changes: 155 additions & 0 deletions audit_uid_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Audit uid/dictionary_generated.go against the DICOM standard's UID registry.

The generated dictionary is a mirror of pydicom's _uid_dict.py plus the handful of
UIDs in generate_uid_dict.py's STANDARD_ADDITIONS. Neither source is the standard,
so this script compares the result against PS3.6 directly: Table A-1 (the UID
registry) and Table A-2 (well-known frames of reference, which pydicom folds into
the same dictionary).

python audit_uid_dict.py # fetch part06.xml, then audit
python audit_uid_dict.py part06.xml # audit an already-downloaded copy

Exits non-zero if the dictionary and the standard disagree, and prints every
disagreement. Run it after bumping the pydicom submodule, and when a new edition
of the standard is published.

This is deliberately not part of CI. It depends on the network and on a document
that changes without reference to this repository, so wiring it into the gate
would turn an unrelated DICOM release into a red build on somebody's pull request.
The Go-side guard for the entries that have no pydicom backing is
TestUIDsAheadOfPydicom in uid/uid_test.go.
"""
import os
import re
import sys
import urllib.request
import xml.etree.ElementTree as ET

PART06_URL = (
"https://dicom.nema.org/medical/dicom/current/source/docbook/part06/part06.xml"
)
DOCBOOK = "http://docbook.org/ns/docbook"
NS = {"d": DOCBOOK}
XMLID = "{http://www.w3.org/XML/1998/namespace}id"

# The published tables carry zero-width spaces so long UIDs and keywords wrap in a
# browser. They are presentation, not data.
ZERO_WIDTH_SPACE = "​"


def fetch(path):
if os.path.exists(path):
return path
print("fetching %s" % PART06_URL, file=sys.stderr)
req = urllib.request.Request(PART06_URL, headers={"User-Agent": "godicom-uid-audit"})
with urllib.request.urlopen(req, timeout=600) as r, open(path, "wb") as f:
f.write(r.read())
print("wrote %s (%d bytes)" % (path, os.path.getsize(path)), file=sys.stderr)
return path


def cell_text(td):
return " ".join("".join(td.itertext()).replace(ZERO_WIDTH_SPACE, "").split())


def table_rows(root, table_id):
for tbl in root.iter("{%s}table" % DOCBOOK):
if tbl.get(XMLID) == table_id:
return [
[cell_text(td) for td in tr.findall("d:td", NS)]
for tr in tbl.findall(".//d:tbody/d:tr", NS)
]
raise SystemExit("table %s not found in the standard" % table_id)


def read_standard(xml_path):
"""Return {uid: (name, uid_type, keyword, retired)} from PS3.6 A-1 and A-2."""
root = ET.parse(xml_path).getroot()
std = {}
for uid, name, keyword, uid_type in (r[:4] for r in table_rows(root, "table_A-1")):
# A retired UID is published with "(Retired)" appended to its name; pydicom
# keeps the two apart, so split them here to compare like with like.
retired = "(Retired)" in name
name = " ".join(name.replace("(Retired)", "").split())
std[uid] = (name, uid_type, keyword, retired)
for uid, name, keyword in (r[:3] for r in table_rows(root, "table_A-2")):
std.setdefault(uid, (name, "Well-known frame of reference", keyword, False))
return std


# Names in the generated file hold no escaped quotes, so matching to the next key
# boundary is exact. If that ever changes this pattern will simply stop matching
# the entry, and the audit will report it as missing rather than silently skip it.
ENTRY = re.compile(
r'"([\d.]+)": \{Name: "(.*?)", Type: "(.*?)", '
r'ExtraInfo: "(.*?)", Retired: (true|false), Keyword: "(.*?)"\},'
)


def read_generated(go_path):
"""Return {uid: (name, uid_type, keyword, retired)} from the generated Go."""
with open(go_path, encoding="utf-8") as f:
src = f.read()
got = {}
for m in ENTRY.finditer(src):
got[m.group(1)] = (m.group(2), m.group(3), m.group(6), m.group(5) == "true")
if not got:
raise SystemExit("parsed no entries out of %s" % go_path)
return got


def numeric(uid):
return [int(part) for part in uid.split(".")]


def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
xml_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(script_dir, "part06.xml")
std = read_standard(fetch(xml_path))
got = read_generated(os.path.join(script_dir, "uid", "dictionary_generated.go"))

print("standard A-1 + A-2: %d UIDs; generated dictionary: %d" % (len(std), len(got)))

problems = 0

missing = sorted(set(std) - set(got), key=numeric)
if missing:
problems += len(missing)
print("\n%d registered UIDs are absent from the dictionary:" % len(missing))
for uid in missing:
name, uid_type, keyword, retired = std[uid]
print(" %s %s %s retired=%s %s" % (uid, uid_type, keyword, retired, name))
print(
"\nAdd them to STANDARD_ADDITIONS in generate_uid_dict.py if pydicom\n"
"still lacks them, or bump the pydicom submodule if it has caught up."
)

extra = sorted(set(got) - set(std), key=numeric)
if extra:
problems += len(extra)
print("\n%d dictionary UIDs are not in A-1 or A-2:" % len(extra))
for uid in extra:
print(" %s %s" % (uid, got[uid][0]))

# Names are compared loosely on purpose: pydicom splits the text after a colon
# into its own field, and edits wording between editions. Keyword and retired
# are what consumers resolve against, so those have to match exactly.
for uid in sorted(set(std) & set(got), key=numeric):
_, _, std_keyword, std_retired = std[uid]
_, _, got_keyword, got_retired = got[uid]
if std_keyword != got_keyword:
problems += 1
print("\n%s keyword: standard %r, generated %r" % (uid, std_keyword, got_keyword))
if std_retired != got_retired:
problems += 1
print("\n%s retired: standard %s, generated %s" % (uid, std_retired, got_retired))

if problems:
print("\n%d discrepancies" % problems)
return 1
print("no discrepancies")
return 0


if __name__ == "__main__":
sys.exit(main())
128 changes: 127 additions & 1 deletion generate_uid_dict.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Generate Go UID dictionary from pydicom's _uid_dict.py."""
"""Generate Go UID dictionary from pydicom's _uid_dict.py, plus STANDARD_ADDITIONS."""
import ast
import os
import subprocess

NATIVE_ENCODING = {
"1.2.840.10008.1.2",
Expand Down Expand Up @@ -47,6 +48,72 @@
"VerificationSOPClass",
}

# UIDs that PS3.6 Table A-1 registers but pydicom's _uid_dict.py does not carry.
#
# The dictionary is otherwise a 1:1 mirror of pydicom's, and staying a mirror is
# the point -- it is what lets `uid` be checked against the same source as the
# rest of the port. These are the exception because the standard, not pydicom, is
# the authority on which UIDs exist: a consumer resolving a Storage SOP Class it
# received over the wire cannot wait for pydicom to catch up.
#
# Established by diffing the whole registry rather than by spotting them one at a
# time. Parsing Table A-1 and A-2 out of part06.xml and comparing against
# uid/dictionary_generated.go gave 496 registered UIDs against 490 generated
# ones: these six were the entire difference, with no extra UIDs on the Go side
# and no name, keyword or retired-flag disagreement anywhere else. Absent from
# pydicom both at the pinned submodule and at upstream main 0e98c4a (2026-08-01).
#
# Values use the same 5-tuple shape as pydicom's UID_dictionary, so the merge
# needs no special casing downstream:
# (name, uid_type, extra_info, retired, keyword)
# extra_info is the text pydicom splits off after a colon in the registered name
# (as in "Implicit VR Little Endian: Default Transfer Syntax for DICOM"); none of
# these names have one. retired is pydicom's literal "Retired" or "".
STANDARD_ADDITIONS = {
"1.2.840.10008.5.1.4.1.1.2.3": (
"CT Image Storage - For Processing",
"SOP Class",
"",
"",
"CTImageStorageForProcessing",
),
"1.2.840.10008.5.1.4.1.1.2.4": (
"Enhanced CT Image Storage - For Processing",
"SOP Class",
"",
"",
"EnhancedCTImageStorageForProcessing",
),
"1.2.840.10008.5.1.4.1.1.2.5": (
"Legacy Converted Enhanced CT Image Storage - For Processing",
"SOP Class",
"",
"",
"LegacyConvertedEnhancedCTImageStorageForProcessing",
),
"1.2.840.10008.5.1.4.1.1.9.100.1": (
"Waveform Presentation State Storage",
"SOP Class",
"",
"",
"WaveformPresentationStateStorage",
),
"1.2.840.10008.5.1.4.1.1.9.100.2": (
"Waveform Acquisition Presentation State Storage",
"SOP Class",
"",
"",
"WaveformAcquisitionPresentationStateStorage",
),
"1.2.840.10008.5.1.4.1.1.601.5": (
"Ultrasound Waveform Storage",
"SOP Class",
"",
"",
"UltrasoundWaveformStorage",
),
}


def parse_uid_dict(filepath):
with open(filepath, encoding="utf-8") as f:
Expand Down Expand Up @@ -97,12 +164,60 @@ def is_valid_go_ident(name):
return all(c.isalnum() or c == "_" for c in name)


def merge_standard_additions(uid_dict):
"""Fold STANDARD_ADDITIONS into the dictionary parsed from pydicom.

Everything that could go wrong with an addition is checked here, before any
output is written, so a bad entry cannot leave a half-regenerated tree behind.

Refusing to run once pydicom defines one of these is deliberate. Skipping it
silently would leave a second definition of a UID pydicom now owns, free to
drift from it forever without anyone noticing -- which is the failure this
table exists to avoid, not to create.
"""
caught_up = sorted(set(STANDARD_ADDITIONS) & set(uid_dict))
if caught_up:
raise SystemExit(
"pydicom now defines %s.\n"
"Drop them from STANDARD_ADDITIONS and re-run: once pydicom has an "
"entry, pydicom is the source of truth for it." % ", ".join(caught_up)
)

# The constant loop skips a keyword that is empty, already taken, reserved to
# MANUAL_CONSTS, or not a valid Go identifier. Those skips are long-settled for
# pydicom's entries, but for an addition the same skip would put a UID in the
# dictionary and quietly ship no constant for it. Checking the four conditions
# up front is equivalent, and reports which one failed.
taken = {entry[4] for entry in uid_dict.values() if entry[4]}
seen = {}
for uid, entry in sorted(STANDARD_ADDITIONS.items()):
keyword = entry[4]
if not is_valid_go_ident(keyword):
raise SystemExit("%s: %r is not a valid Go identifier" % (uid, keyword))
if keyword in MANUAL_CONSTS:
raise SystemExit(
"%s: keyword %s is reserved to MANUAL_CONSTS" % (uid, keyword)
)
if keyword in taken:
raise SystemExit(
"%s: keyword %s is already used by a pydicom entry" % (uid, keyword)
)
if keyword in seen:
raise SystemExit(
"%s and %s both use keyword %s" % (seen[keyword], uid, keyword)
)
seen[keyword] = uid

uid_dict.update(STANDARD_ADDITIONS)


def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
uid_dict_path = os.path.join(
script_dir, "pydicom", "src", "pydicom", "_uid_dict.py"
)
uid_dict = parse_uid_dict(uid_dict_path)
merge_standard_additions(uid_dict)

dict_lines = []
dict_lines.append("package uid")
Expand Down Expand Up @@ -164,6 +279,17 @@ def main():
with open(const_path, "w", encoding="utf-8") as f:
f.write("\n".join(const_lines) + "\n")

# CI's gofmt gate skips '*_generated.go', which "generated.go" does not match,
# so this file has to be formatted. The constants are emitted one per line with
# a single tab and gofmt turns them into an aligned block; doing it here rather
# than leaving it to whoever runs the script keeps a regeneration from showing
# up as a ~1000-line whitespace diff on top of the real change.
#
# dictionary_generated.go is left alone on purpose: it does match the exclusion
# and has always been committed unformatted, so formatting it now would realign
# a 900-entry map literal for no benefit.
subprocess.run(["gofmt", "-w", const_path], check=True)

print(f"Generated {dict_path} with {len(uid_dict)} UIDs")
print(f"Generated {const_path} with {len(seen_keywords)} constants")

Expand Down
Loading
Loading