Source code for fileformats.extras.medimage.deid_recipe
import logging
import os
import stat
import typing as ty
import deid.config
import yaml
from deid.dicom.parser import DicomParser
from fileformats.core import FileSet, extra_implementation
from fileformats.medimage import DeidRecipe, DeidRecipeX, DeidSalt, DeidTransforms
from .deid_transforms import (
DeidFunction,
DeidTransformsError,
DeidTransformsSpec,
VariableBuilder,
load_transforms,
)
logger = logging.getLogger(__name__)
[docs]
class DeidRecipeWithTransforms(deid.config.DeidRecipe): # type: ignore[misc]
"""A `deid.config.DeidRecipe` together with the transforms that define the values
of the "var:" and "func:" references in the recipe
Parameters
----------
deid : str, optional
path to the deid recipe file
transforms : DeidTransformsSpec or Mapping[str, VariableBuilder], optional
the loaded transforms, or just the variable builders, keyed by the names of the
variables they define
**kwargs
passed through to `deid.config.DeidRecipe`
Raises
------
ValueError
if the recipe references variables or functions that aren't defined
"""
transforms: dict[str, VariableBuilder]
functions: dict[str, DeidFunction]
def __init__(
self,
deid: str | None = None,
transforms: DeidTransformsSpec | ty.Mapping[str, VariableBuilder] | None = None,
**kwargs: ty.Any,
) -> None:
super().__init__(deid, **kwargs)
if isinstance(transforms, DeidTransformsSpec):
self.transforms = dict(transforms.variables)
self.functions = dict(transforms.functions)
else:
self.transforms = dict(transforms) if transforms else {}
self.functions = {}
for kind, referenced, defined in (
("var:", self.variables, self.transforms),
("func:", self.function_names, self.functions),
):
missing = referenced - set(defined)
if missing:
raise ValueError(
f"Recipe references {kind} {sorted(missing)} but they aren't "
f"defined. Define them in a '{DeidTransforms.ext}' side-car to "
"the recipe"
)
def _references(self, prefix: str) -> set[str]:
"""Names referenced by values starting with `prefix` anywhere in the recipe"""
names: set[str] = set()
def walk(node: ty.Any) -> None:
if isinstance(node, dict):
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
elif isinstance(node, str):
names.update(
t[len(prefix) :] for t in node.split() if t.startswith(prefix)
)
walk(self.deid)
return names
@property
def variables(self) -> set[str]:
"""Names of the variables referenced by 'var:' values anywhere in the recipe"""
return self._references("var:")
@property
def function_names(self) -> set[str]:
"""Names of the functions referenced by 'func:' values anywhere in the recipe
(NB: not 'deid_func:', which are provided by deid)"""
return self._references("func:")
[docs]
def parser(self, dicom_file: os.PathLike[str] | str) -> DicomParser:
"""Creates a parser to deidentify the given DICOM file with the recipe, with
the variables defined by the transforms evaluated against the file
Parameters
----------
dicom_file : PathLike
the DICOM file to deidentify
Returns
-------
DicomParser
the parser, ready to `parse()` and `save()`
"""
parser = DicomParser(str(dicom_file), recipe=self)
for func_name, func in self.functions.items():
parser.define(func_name, func)
for var_name, builder in self.transforms.items():
try:
value = builder(parser.dicom)
parser.define(var_name, value)
except Exception as exc: # noqa: BLE001
logger.warning(
"Variable builder for '%s' raised %s: %s — skipping",
var_name,
type(exc).__name__,
exc,
)
return parser
@extra_implementation(FileSet.load)
def deid_salt_load(salt: DeidSalt, **kwargs: ty.Any) -> bytes:
mode = salt.fspath.stat().st_mode
if mode & (stat.S_IRWXG | stat.S_IRWXO):
logger.warning(
"Deidentification salt file '%s' is accessible by other users (mode %s), "
"it should only be readable by the user running the deidentification",
salt.fspath,
oct(stat.S_IMODE(mode)),
)
return salt.fspath.read_bytes().strip()
@extra_implementation(FileSet.load)
def deid_transforms_load(
transforms: DeidTransforms, salt: bytes | None = None, **kwargs: ty.Any
) -> DeidTransformsSpec:
"""Loads the transforms, compiling their expressions so that any errors in them
are reported now. `salt` is the key from the salt side-car of the recipe, used by
salted hashes"""
with transforms.open() as f:
try:
spec = yaml.safe_load(f)
except yaml.YAMLError as e:
raise DeidTransformsError(
f"Could not parse transforms file '{transforms.fspath}': {e}"
) from e
try:
return load_transforms(spec, salt=salt)
except DeidTransformsError as e:
raise DeidTransformsError(f"In '{transforms.fspath}': {e}") from e
@extra_implementation(FileSet.load)
def deid_recipe_load(recipe: DeidRecipe, **kwargs: ty.Any) -> DeidRecipeWithTransforms:
return DeidRecipeWithTransforms(str(recipe.fspath), **kwargs)
@extra_implementation(FileSet.load)
def deid_recipe_x_load(
recipe: DeidRecipeX, **kwargs: ty.Any
) -> DeidRecipeWithTransforms:
salt = recipe.salt_file.load() if recipe.salt_file is not None else None
return DeidRecipeWithTransforms(
str(recipe.fspath), transforms=recipe.transforms_file.load(salt=salt), **kwargs
)