Repository: blairconrad/dicognito

Issue:
Please fully document which attributes are removed/cleared/modified
The current readme states:
> Anonymization causes significant attributes, such as identifiers, names, and addresses, to be replaced by new values

Please document exactly which attributes are modified

Discussion:

Patch:
diff --git a/README.md b/README.md
index dcf950e..fb7031f 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,48 @@ for original_filename in ("original1.dcm", "original2.dcm"):
 Use a single `Anonymizer` on datasets that might be part of the same series, or the identifiers will not be
 consistent across objects.
 
+## Exactly what does dicognito do?
+* Add "DICOGNITO" to DeidentificationMethod
+* Remove BranchOfService
+* Remove MedicalRecordLocator
+* Remove MilitaryRank
+* Remove Occupation
+* Remove PatientInsurancePlanCodeSequence
+* Remove PatientReligiousPreference
+* Remove PatientTelecomInformation
+* Remove PatientTelephoneNumbers
+* Remove ReferencedPatientPhotoSequence
+* Remove ResponsibleOrganization
+* Replace AccessionNumber with anonymized values
+* Replace CountryOfResidence with anonymized values
+* Replace CurrentPatientLocation with ""
+* Replace FillerOrderNumberImagingServiceRequest with anonymized values
+* Replace FillerOrderNumberImagingServiceRequestRetired with anonymized values
+* Replace FillerOrderNumberProcedure with anonymized values
+* Replace InstitutionAddress with anonymized values
+* Replace InstitutionName with anonymized values
+* Replace InstitutionalDepartmentName with anonymized values
+* Replace IssuerOfPatientID with "DICOGNITO"
+* Replace OtherPatientIDs with anonymized values
+* Replace PatientAddress with anonymized values
+* Replace PatientID with anonymized values
+* Replace PerformedProcedureStepID with anonymized values
+* Replace PlacerOrderNumberImagingServiceRequest with anonymized values
+* Replace PlacerOrderNumberImagingServiceRequestRetired with anonymized values
+* Replace PlacerOrderNumberProcedure with anonymized values
+* Replace RegionOfResidence with anonymized values
+* Replace RequestedProcedureID with anonymized values
+* Replace RequestingService with ""
+* Replace ScheduledProcedureStepID with anonymized values
+* Replace StationName with anonymized values
+* Replace StudyID with anonymized values
+* Replace all DA attributes with anonymized values that precede the originals
+* Replace all DT attributes with anonymized values that precede the originals
+* Replace all PN attributes with anonymized values
+* Replace all TM attributes with anonymized values that precede the originals
+* Replace all UI attributes with anonymized values
+* Replace private "MITRA LINKED ATTRIBUTES 1.0" attribute "Global Patient ID" with anonymized values
+* Set PatientIdentityRemoved to "YES" if BurnedInAnnotation is "NO"
 ----
 Logo: Remixed from [Radiology](https://thenounproject.com/search/?q=x-ray&i=1777366)
 by [priyanka](https://thenounproject.com/creativepriyanka/) and [Incognito](https://thenounproject.com/search/?q=incognito&i=7572) by [d͡ʒɛrmi Good](https://thenounproject.com/geremygood/) from [the Noun Project](https://thenounproject.com/).
diff --git a/src/dicognito/addressanonymizer.py b/src/dicognito/addressanonymizer.py
index 5888ba9..87d48b6 100644
--- a/src/dicognito/addressanonymizer.py
+++ b/src/dicognito/addressanonymizer.py
@@ -1,7 +1,8 @@
 import pydicom
 
 
-from typing import Any
+from typing import Any, Iterator
+from dicognito.element_anonymizer import ElementAnonymizer
 from dicognito.randomizer import Randomizer
 
 """\
@@ -9,7 +10,7 @@ Defines AddressAnonymizer, responsible for anonymizing addresses
 """
 
 
-class AddressAnonymizer:
+class AddressAnonymizer(ElementAnonymizer):
     """\
     Anonymizes addresses.
     """
@@ -25,9 +26,9 @@ class AddressAnonymizer:
         """
         self.randomizer = randomizer
 
-        address_tag = pydicom.datadict.tag_for_keyword("PatientAddress")
-        region_tag = pydicom.datadict.tag_for_keyword("RegionOfResidence")
-        country_tag = pydicom.datadict.tag_for_keyword("CountryOfResidence")
+        address_tag = pydicom.datadict.keyword_dict["PatientAddress"]
+        region_tag = pydicom.datadict.keyword_dict["RegionOfResidence"]
+        country_tag = pydicom.datadict.keyword_dict["CountryOfResidence"]
 
         self._value_factories = {
             address_tag: self.get_street_address,
@@ -63,6 +64,12 @@ class AddressAnonymizer:
         data_element.value = value_factory(data_element.value)
         return True
 
+    def describe_actions(self) -> Iterator[str]:
+        yield from map(
+            lambda keyword: f"Replace {keyword} with anonymized values",
+            map(pydicom.datadict.keyword_for_tag, self._value_factories.keys()),
+        )
+
     def get_street_address(self, original_value: Any) -> str:
         (street_number_index, street_index) = self.randomizer.get_ints_from_ranges(
             original_value, 1000, len(self._streets)
diff --git a/src/dicognito/anonymizer.py b/src/dicognito/anonymizer.py
index 1113636..933100f 100644
--- a/src/dicognito/anonymizer.py
+++ b/src/dicognito/anonymizer.py
@@ -1,8 +1,10 @@
 """\
 Defines Anonymizer, the principle class used to anonymize DICOM objects.
 """
-from typing import Callable, Optional, Sequence
+from typing import Iterator, Optional, Sequence
 from dicognito.addressanonymizer import AddressAnonymizer
+from dicognito.dataset_updater import DatasetUpdater, DeidentificationMethodUpdater, PatientIdentityRemovedUpdater
+from dicognito.element_anonymizer import ElementAnonymizer
 from dicognito.equipmentanonymizer import EquipmentAnonymizer
 from dicognito.fixedvalueanonymizer import FixedValueAnonymizer
 from dicognito.idanonymizer import IDAnonymizer
@@ -66,7 +68,7 @@ class Anonymizer:
         )
         address_anonymizer = AddressAnonymizer(randomizer)
 
-        self._element_handlers: Sequence[Callable[[pydicom.dataset.Dataset, pydicom.dataelem.DataElement], bool]] = [
+        self._element_handlers: Sequence[ElementAnonymizer] = [
             UnwantedElementsStripper(
                 "BranchOfService",
                 "Occupation",
@@ -107,6 +109,11 @@ class Anonymizer:
             DateTimeAnonymizer(date_offset_hours),
         ]
 
+        self._dataset_updaters: Sequence[DatasetUpdater] = [
+            DeidentificationMethodUpdater(),
+            PatientIdentityRemovedUpdater(),
+        ]
+
     def anonymize(self, dataset: pydicom.dataset.Dataset) -> None:
         """\
         Anonymize a dataset in place. Replaces all PNs, UIs, dates and times, and
@@ -119,29 +126,19 @@ class Anonymizer:
         """
         dataset.file_meta.walk(self._anonymize_element)
         dataset.walk(self._anonymize_element)
-        self._update_deidentification_method(dataset)
-        self._update_patient_identity_removed(dataset)
+        for updater in self._dataset_updaters:
+            updater(dataset)
+
+    def describe_actions(self) -> str:
+        def actions() -> Iterator[str]:
+            for handler in self._element_handlers:
+                yield from handler.describe_actions()
+            for updater in self._dataset_updaters:
+                yield from updater.describe_actions()
+
+        return "* " + "\n* ".join(sorted(actions()))
 
     def _anonymize_element(self, dataset: pydicom.dataset.Dataset, data_element: pydicom.dataelem.DataElement) -> None:
         for handler in self._element_handlers:
             if handler(dataset, data_element):
                 return
-
-    def _update_deidentification_method(self, dataset: pydicom.dataset.Dataset) -> None:
-        if "DeidentificationMethod" not in dataset:
-            dataset.DeidentificationMethod = "DICOGNITO"
-            return
-
-        existing_element = dataset.data_element("DeidentificationMethod")
-        assert existing_element is not None  # satisfy mypy
-        existing_value = existing_element.value
-
-        if isinstance(existing_value, pydicom.multival.MultiValue):
-            if "DICOGNITO" not in existing_value:
-                existing_value.append("DICOGNITO")
-        elif existing_value != "DICOGNITO":
-            existing_element.value = [existing_value, "DICOGNITO"]
-
-    def _update_patient_identity_removed(self, dataset: pydicom.dataset.Dataset) -> None:
-        if dataset.get("BurnedInAnnotation", "YES") == "NO":
-            dataset.PatientIdentityRemoved = "YES"
diff --git a/src/dicognito/dataset_updater.py b/src/dicognito/dataset_updater.py
new file mode 100644
index 0000000..804b3ed
--- /dev/null
+++ b/src/dicognito/dataset_updater.py
@@ -0,0 +1,57 @@
+from typing import Iterator
+import pydicom
+
+
+class DatasetUpdater:
+    def __call__(self, dataset: pydicom.dataset.Dataset) -> None:
+        raise NotImplementedError()
+
+    def describe_actions(self) -> Iterator[str]:
+        raise NotImplementedError()
+
+
+class DeidentificationMethodUpdater(DatasetUpdater):
+    def __call__(self, dataset: pydicom.dataset.Dataset) -> None:
+        """\
+        Update the DeidentificationMethod in a dataset replacing its
+        value with DICOGNITO.
+
+        Parameters
+        ----------
+        dataset : pydicom.dataset.Dataset
+            The dataset to operate on.
+        """
+        if "DeidentificationMethod" not in dataset:
+            dataset.DeidentificationMethod = "DICOGNITO"
+            return
+
+        existing_element = dataset.data_element("DeidentificationMethod")
+        assert existing_element is not None  # satisfy mypy
+        existing_value = existing_element.value
+
+        if isinstance(existing_value, pydicom.multival.MultiValue):
+            if "DICOGNITO" not in existing_value:
+                existing_value.append("DICOGNITO")
+        elif existing_value != "DICOGNITO":
+            existing_element.value = [existing_value, "DICOGNITO"]
+
+    def describe_actions(self) -> Iterator[str]:
+        yield 'Add "DICOGNITO" to DeidentificationMethod'
+
+
+class PatientIdentityRemovedUpdater(DatasetUpdater):
+    def __call__(self, dataset: pydicom.dataset.Dataset) -> None:
+        """\
+        Update the PatientIdentityRemoved in a dataset, replacing its
+        value with YES as long as BurnedInAnnotation is "NO".
+
+        Parameters
+        ----------
+        dataset : pydicom.dataset.Dataset
+            The dataset to operate on.
+        """
+        if dataset.get("BurnedInAnnotation", "YES") == "NO":
+            dataset.PatientIdentityRemoved = "YES"
+
+    def describe_actions(self) -> Iterator[str]:
+        yield 'Set PatientIdentityRemoved to "YES" if BurnedInAnnotation is "NO"'
diff --git a/src/dicognito/datetimeanonymizer.py b/src/dicognito/datetimeanonymizer.py
index 3d8c452..21b7847 100644
--- a/src/dicognito/datetimeanonymizer.py
+++ b/src/dicognito/datetimeanonymizer.py
@@ -1,8 +1,11 @@
 import datetime
+from typing import Iterator
 import pydicom
 
+from dicognito.element_anonymizer import ElementAnonymizer
 
-class DateTimeAnonymizer:
+
+class DateTimeAnonymizer(ElementAnonymizer):
     def __init__(self, offset_hours: int) -> None:
         """\
         Create a new DateTimeAnonymizer.
@@ -45,6 +48,11 @@ class DateTimeAnonymizer:
             self._anonymize_datetime(dataset, data_element)
         return True
 
+    def describe_actions(self) -> Iterator[str]:
+        yield "Replace all DA attributes with anonymized values that precede the originals"
+        yield "Replace all DT attributes with anonymized values that precede the originals"
+        yield "Replace all TM attributes with anonymized values that precede the originals"
+
     def _anonymize_date_and_time(self, dataset: pydicom.dataset.Dataset, data_element: pydicom.DataElement) -> None:
         date_value = data_element.value
         if isinstance(data_element.value, pydicom.multival.MultiValue):
diff --git a/src/dicognito/element_anonymizer.py b/src/dicognito/element_anonymizer.py
new file mode 100644
index 0000000..ac2a71e
--- /dev/null
+++ b/src/dicognito/element_anonymizer.py
@@ -0,0 +1,11 @@
+from typing import Iterator
+from pydicom import DataElement
+from pydicom.dataset import Dataset
+
+
+class ElementAnonymizer:
+    def __call__(self, dataset: Dataset, data_element: DataElement) -> bool:
+        raise NotImplementedError
+
+    def describe_actions(self) -> Iterator[str]:
+        raise NotImplementedError
diff --git a/src/dicognito/equipmentanonymizer.py b/src/dicognito/equipmentanonymizer.py
index 30e1a7a..d151d76 100644
--- a/src/dicognito/equipmentanonymizer.py
+++ b/src/dicognito/equipmentanonymizer.py
@@ -1,9 +1,11 @@
+from typing import Iterator
 import pydicom
 
 from dicognito.addressanonymizer import AddressAnonymizer
+from dicognito.element_anonymizer import ElementAnonymizer
 
 
-class EquipmentAnonymizer:
+class EquipmentAnonymizer(ElementAnonymizer):
     def __init__(self, address_anonymizer: AddressAnonymizer) -> None:
         """\
         Create a new EquipmentAnonymizer.
@@ -16,9 +18,9 @@ class EquipmentAnonymizer:
         self.address_anonymizer = address_anonymizer
 
         self._element_anonymizers = {
-            pydicom.datadict.tag_for_keyword("InstitutionName"): self.anonymize_institution_name,
-            pydicom.datadict.tag_for_keyword("InstitutionAddress"): self.anonymize_institution_address,
-            pydicom.datadict.tag_for_keyword("InstitutionalDepartmentName"): self.anonymize_department_name,
+            pydicom.datadict.keyword_dict["InstitutionName"]: self.anonymize_institution_name,
+            pydicom.datadict.keyword_dict["InstitutionAddress"]: self.anonymize_institution_address,
+            pydicom.datadict.keyword_dict["InstitutionalDepartmentName"]: self.anonymize_department_name,
         }
 
     def __call__(self, dataset: pydicom.dataset.Dataset, data_element: pydicom.DataElement) -> bool:
@@ -49,6 +51,12 @@ class EquipmentAnonymizer:
         element_anonymizer(dataset, data_element)
         return True
 
+    def describe_actions(self) -> Iterator[str]:
+        yield from map(
+            lambda keyword: f"Replace {keyword} with anonymized values",
+            map(pydicom.datadict.keyword_for_tag, self._element_anonymizers),
+        )
+
     def anonymize_institution_name(self, dataset: pydicom.dataset.Dataset, data_element: pydicom.DataElement) -> None:
         region = self.address_anonymizer.get_region(data_element.value)
         street_address = self.address_anonymizer.get_street_address(data_element.value)
diff --git a/src/dicognito/fixedvalueanonymizer.py b/src/dicognito/fixedvalueanonymizer.py
index f9513d3..1075f7c 100644
--- a/src/dicognito/fixedvalueanonymizer.py
+++ b/src/dicognito/fixedvalueanonymizer.py
@@ -1,9 +1,11 @@
-from typing import Any
+from typing import Any, Iterator
 
 import pydicom
 
+from dicognito.element_anonymizer import ElementAnonymizer
 
-class FixedValueAnonymizer:
+
+class FixedValueAnonymizer(ElementAnonymizer):
     def __init__(self, keyword: str, value: Any) -> None:
         """\
         Create a new FixedValueAnonymizer.
@@ -16,7 +18,7 @@ class FixedValueAnonymizer:
         value
             The new value to assign to the element.
         """
-        self.tag = pydicom.datadict.tag_for_keyword(keyword)
+        self.tag: int = pydicom.datadict.keyword_dict[keyword]
         self.value = value
 
     def __call__(self, dataset: pydicom.dataset.Dataset, data_element: pydicom.DataElement) -> bool:
@@ -42,3 +44,6 @@ class FixedValueAnonymizer:
             data_element.value = self.value
             return True
         return False
+
+    def describe_actions(self) -> Iterator[str]:
+        yield f'Replace {pydicom.datadict.keyword_for_tag(self.tag)} with "{self.value}"'
diff --git a/src/dicognito/idanonymizer.py b/src/dicognito/idanonymizer.py
index cb0d811..c0349ff 100644
--- a/src/dicognito/idanonymizer.py
+++ b/src/dicognito/idanonymizer.py
@@ -1,10 +1,11 @@
-from typing import Any
+from typing import Any, Iterator
 import pydicom
+from dicognito.element_anonymizer import ElementAnonymizer
 
 from dicognito.randomizer import Randomizer
 
 
-class IDAnonymizer:
+class IDAnonymizer(ElementAnonymizer):
     _alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
 
     def __init__(self, randomizer: Randomizer, id_prefix: str, id_suffix: str, *keywords: str):
@@ -28,8 +29,8 @@ class IDAnonymizer:
         self.randomizer = randomizer
         self.id_prefix = id_prefix
         self.id_suffix = id_suffix
-        self.issuer_tag = pydicom.datadict.tag_for_keyword("IssuerOfPatientID")
-        self.id_tags = [pydicom.datadict.tag_for_keyword(tag_name) for tag_name in keywords]
+        self.issuer_tag = pydicom.datadict.keyword_dict["IssuerOfPatientID"]
+        self.id_tags = [pydicom.datadict.keyword_dict[tag_name] for tag_name in keywords]
 
         total_affixes_length = len(self.id_prefix) + len(self.id_suffix)
         self._indices_for_randomizer = [len(self._alphabet)] * (12 - total_affixes_length)
@@ -68,6 +69,14 @@ class IDAnonymizer:
             return True
         return False
 
+    def describe_actions(self) -> Iterator[str]:
+        yield from map(
+            lambda keyword: f"Replace {keyword} with anonymized values",
+            map(pydicom.datadict.keyword_for_tag, self.id_tags),
+        )
+        yield 'Replace IssuerOfPatientID with "DICOGNITO"'
+        yield 'Replace private "MITRA LINKED ATTRIBUTES 1.0" attribute "Global Patient ID" with anonymized values'
+
     def _anonymize_mitra_global_patient_id(
         self, dataset: pydicom.dataset.Dataset, data_element: pydicom.DataElement
     ) -> bool:
diff --git a/src/dicognito/pnanonymizer.py b/src/dicognito/pnanonymizer.py
index a83dd3c..47f4c5f 100644
--- a/src/dicognito/pnanonymizer.py
+++ b/src/dicognito/pnanonymizer.py
@@ -1,10 +1,11 @@
 import pydicom
 
-from typing import Any, Optional
+from typing import Any, Iterator, Optional
+from dicognito.element_anonymizer import ElementAnonymizer
 from dicognito.randomizer import Randomizer
 
 
-class PNAnonymizer:
+class PNAnonymizer(ElementAnonymizer):
     def __init__(self, randomizer: Randomizer):
         """\
         Create a new PNAnonymizer.
@@ -45,6 +46,9 @@ class PNAnonymizer:
             data_element.value = self._new_pn(patient_sex, data_element.value)
         return True
 
+    def describe_actions(self) -> Iterator[str]:
+        yield "Replace all PN attributes with anonymized values"
+
     def _new_pn(self, sex: Optional[str], original_value: Any) -> str:
         if sex == "F":
             first_names = self._female_first_names
diff --git a/src/dicognito/release_notes.md b/src/dicognito/release_notes.md
index 12235d2..f546ae6 100644
--- a/src/dicognito/release_notes.md
+++ b/src/dicognito/release_notes.md
@@ -5,6 +5,7 @@
 ### New
 
 - Officially support Python 3.11 ([#136](https://github.com/blairconrad/dicognito/issues/136))
+- Complete list of attribute changes now listed in README ([#140](https://github.com/blairconrad/dicognito/issues/140))
 
 ## 0.15.0
 
diff --git a/src/dicognito/uianonymizer.py b/src/dicognito/uianonymizer.py
index 0a05357..90b9e86 100644
--- a/src/dicognito/uianonymizer.py
+++ b/src/dicognito/uianonymizer.py
@@ -1,10 +1,12 @@
+from typing import Iterator
 import pydicom
 import pydicom.dataelem
+from dicognito.element_anonymizer import ElementAnonymizer
 
 from dicognito.randomizer import Randomizer
 
 
-class UIAnonymizer:
+class UIAnonymizer(ElementAnonymizer):
     def __init__(self, randomizer: Randomizer) -> None:
         """\
         Create a new UIAnonymizer.
@@ -44,5 +46,8 @@ class UIAnonymizer:
             data_element.value = self._new_ui(data_element.value)
         return True
 
+    def describe_actions(self) -> Iterator[str]:
+        yield "Replace all UI attributes with anonymized values"
+
     def _new_ui(self, ui: str) -> str:
         return "2." + str(10**39 + self._randomizer.to_int(ui))
diff --git a/src/dicognito/unwantedelements.py b/src/dicognito/unwantedelements.py
index 8a6e124..00ebe85 100644
--- a/src/dicognito/unwantedelements.py
+++ b/src/dicognito/unwantedelements.py
@@ -1,7 +1,10 @@
+from typing import Iterator
 import pydicom
 
+from dicognito.element_anonymizer import ElementAnonymizer
 
-class UnwantedElementsStripper:
+
+class UnwantedElementsStripper(ElementAnonymizer):
     def __init__(self, *keywords: str):
         """\
         Create a new UnwantedElementsStripper.
@@ -12,7 +15,7 @@ class UnwantedElementsStripper:
             All of the keywords for elements to be removed from the
             dataset.
         """
-        self.tags = [pydicom.datadict.tag_for_keyword(keyword) for keyword in keywords]
+        self.tags = [pydicom.datadict.keyword_dict[keyword] for keyword in keywords]
 
     def __call__(self, dataset: pydicom.dataset.Dataset, data_element: pydicom.DataElement) -> bool:
         """\
@@ -38,3 +41,9 @@ class UnwantedElementsStripper:
             del dataset[data_element.tag]
             return True
         return False
+
+    def describe_actions(self) -> Iterator[str]:
+        yield from map(
+            lambda keyword: f"Remove {keyword}",
+            map(pydicom.datadict.keyword_for_tag, self.tags),
+        )


Test patch:
diff --git a/tests/test_readme.py b/tests/test_readme.py
new file mode 100644
index 0000000..5770564
--- /dev/null
+++ b/tests/test_readme.py
@@ -0,0 +1,28 @@
+from os.path import abspath, join
+from itertools import dropwhile, islice, takewhile
+from typing import Iterator
+
+from dicognito.anonymizer import Anonymizer
+
+
+def test_anonymized_attributes_described():
+    def before_description(line: str) -> bool:
+        return not line.startswith("## Exactly what does dicognito do?")
+
+    def still_description(line: str) -> bool:
+        return not line.startswith("#") and not line.startswith("----")
+
+    def drop_header(iterable: Iterator[str]) -> Iterator[str]:
+        return islice(iterable, 1, None)
+
+    data_root = abspath(join(__file__, "..", ".."))
+    with open(join(data_root, "README.md"), "r") as readme:
+        anonymizer = Anonymizer()
+
+        readme_description = "".join(
+            takewhile(still_description, drop_header(dropwhile(before_description, readme)))
+        ).strip()
+        anonymizer_description = anonymizer.describe_actions()
+
+    print(anonymizer_description)
+    assert readme_description == anonymizer_description