"""Anonymization of ECG files under a source path.
The source path is one dataset, or with ``datasets=True`` each of its
sub-folders is one. In a dataset, files (only those under folders named
``patients_dir_name`` when it is given) are detected with the ecgdatakit parsers,
anonymized by the format handler, checked (see ``_verify``), then moved
into the dataset's output folder. The dataset's CSV catalog records every
file. A file already in the catalog with the same size and modification
time is not read again; a changed file is anonymized again and a deleted
file loses its anonymized copy.
"""
from __future__ import annotations
import hashlib
import logging
import os
import shutil
import socket
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from ecgdatakit import __version__
from ecgdatakit.anonymize._catalog import ACTIVE, SEPARATOR, Catalog
from ecgdatakit.anonymize._codes import new_code, new_ecg_code
from ecgdatakit.anonymize._filenames import anonymized_file_name, split_name
from ecgdatakit.anonymize._identity import (
Identity,
Replacer,
looks_like_date,
matches_folder,
)
from ecgdatakit.anonymize._stats import (
STATS_NAME,
FileRecord,
Stat,
compare,
write_stats,
)
from ecgdatakit.anonymize._verify import VerificationError, verify
from ecgdatakit.anonymize.formats import HANDLERS, Codes, Handler, Spliced
from ecgdatakit.anonymize.formats._base import is_data
from ecgdatakit.anonymize.formats.wfdb import WFDBHandler
from ecgdatakit.parsing.parser import FileParser
log = logging.getLogger("ecgdatakit.anonymize")
_SNIFF = 4096
_SKIP_DIRS = {"#recycle", "@eaDir", "#snapshot", ".snapshot"}
_STAGING = ".anonymize-staging"
_LOCK = ".anonymize.lock"
_SAVE_EVERY = 200
def _now() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def _hidden(name: str) -> bool:
return name.startswith(".") or name in _SKIP_DIRS
@dataclass
class Entry:
"""A raw file (or WFDB record) found in a dataset."""
path: Path
rel: str
patient_folder: str
companions: list[Path] = field(default_factory=list)
size: int = 0
mtime_ns: int = 0
handler: Handler | None = None
[docs]
@dataclass
class Report:
dataset: str
found: int = 0
anonymized: int = 0
copied: int = 0
changed: int = 0
unchanged: int = 0
deleted: int = 0
failed: int = 0
risks: int = 0
skipped_unsupported: int = 0
waiting: int = 0
raw_files: int = 0
"""Raw files found by this run (the same as *found*)."""
anonymized_files: int = 0
"""Of these, files with an anonymized copy on disk."""
ecg_codes: int = 0
"""Distinct ECG pseudonyms of these files."""
patients: int = 0
"""Distinct patients of these files (patient folders, plus files
outside a patient folder, each counted as one patient)."""
patient_codes: int = 0
"""Distinct patient pseudonyms of these files."""
errors: list[str] = field(default_factory=list)
stats: list = field(default_factory=list)
"""Counts of the raw and anonymized files of the whole dataset, read
from disk (see :meth:`Anonymizer.stats`), also written to
``anonymization_stats.csv``."""
empty_patient_folders: list[str] = field(default_factory=list)
"""Raw patient folders holding no ECG file: nothing is copied for them."""
@property
def ok(self) -> bool:
return not self.errors and not self.failed
[docs]
class DatasetLocked(RuntimeError):
"""Another process is anonymizing this dataset."""
class _DatasetLock:
def __init__(self, dataset: Path, stale_seconds: float) -> None:
self.path = dataset / _LOCK
self.stale = stale_seconds
def __enter__(self):
for _ in range(2):
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
age = time.time() - self.path.stat().st_mtime
if age < self.stale:
raise DatasetLocked(f"{self.path.parent.name} is being anonymized "
f"by another process ({self.path})") from None
self.path.unlink(missing_ok=True)
continue
with os.fdopen(fd, "w") as f:
f.write(f"{socket.gethostname()} {os.getpid()} {_now()}\n")
return self
raise DatasetLocked(f"cannot lock {self.path}")
def __exit__(self, *exc):
self.path.unlink(missing_ok=True)
[docs]
class Anonymizer:
"""Anonymize the ECG files under a source path.
Raw files are only read, never modified.
Parameters
----------
source : path
Folder to anonymize, or a single file. A file is anonymized on its
own: its folder holds the output folder and the catalog.
datasets : bool
``False`` (default): *source* is one dataset. ``True``: every
sub-folder of *source* is a separate dataset, with its own output
folder and catalog.
patients_dir_name : str, optional
Name of the folder that holds the patient folders, the same in every
dataset (for example ``"RAW"``). Only files under this folder are
read. When ``None``, every file of every folder is read (except the
output folder).
group_by_patient_folders : bool
``False`` (default): files are not grouped; each file gets its own
patient pseudonym, and the patient ID field is not replaced (it may
hold something else than a patient ID). ``True``: each patient
folder is one patient and its files share one patient pseudonym.
The patient folders are the sub-folders of the *patients_dir_name*
folders, or without *patients_dir_name* the folders directly inside
the dataset. The patient ID field of a file in a patient folder is
replaced by the patient pseudonym (a date is kept); a value that
does not match the folder name is reported as risk.
anonymize_patient_folders : bool
``False`` (default): folder names are copied as they are. ``True``
(needs *group_by_patient_folders*): each patient folder is named
after the patient pseudonym in the output, and its name is replaced
wherever it appears in the files. Every other folder, sub-folders of
the patient folder included, keeps its name.
out_dir : str
Output folder created in each dataset, with the same sub-folders
as the dataset.
catalog_name : str
CSV catalog file created in each dataset.
threads : int
Parallel workers.
settle_seconds : float
Files modified less than this many seconds ago are left for a later
run (they may still be copied).
lock_stale_seconds : float
Age after which a lock left by a stopped run is ignored.
dry_run : bool
Report what would be done, write nothing.
"""
[docs]
def __init__(self, source: str | Path, datasets: bool = False,
patients_dir_name: str | None = None, group_by_patient_folders: bool = False,
anonymize_patient_folders: bool = False,
out_dir: str = "ANONYMIZED", catalog_name: str = "anonymization_catalog.csv",
threads: int = 8, settle_seconds: float = 0.0,
lock_stale_seconds: float = 12 * 3600, dry_run: bool = False) -> None:
source = Path(source).resolve()
self.single_file: Path | None = None
if source.is_file():
if datasets:
raise ValueError("A single file cannot hold datasets")
self.single_file, source = source, source.parent
elif not source.is_dir():
raise FileNotFoundError(f"Source not found: {source}")
self.source = source
self.multi = datasets
self.patients_dir_name = patients_dir_name or None
self.by_folder = bool(group_by_patient_folders)
self.folders = bool(anonymize_patient_folders)
if self.folders and not self.by_folder:
raise ValueError("Renaming patient folders needs group_by_patient_folders "
"(--group-by-patient-folders): without it there are no "
"patient folders")
self.out_dir = out_dir
self.catalog_name = catalog_name
self.threads = max(1, int(threads))
self.settle = settle_seconds
self.lock_stale = lock_stale_seconds
self.dry_run = dry_run
self._parsers = [p for p in FileParser().parsers if p.__name__ in HANDLERS]
# -- datasets ---------------------------------------------------------------
[docs]
def datasets(self) -> list[Path]:
"""The source itself, or its sub-folders with ``datasets=True``."""
if not self.multi:
return [self.source]
return sorted(p for p in self.source.iterdir() if p.is_dir() and not _hidden(p.name))
[docs]
def dataset_of(self, path: Path) -> Path:
path = Path(path).resolve()
try:
rel = path.relative_to(self.source)
except ValueError:
raise ValueError(f"{path} is not inside the source folder {self.source}") from None
if not self.multi:
return self.source
if not rel.parts:
raise ValueError("Give a path inside a dataset, or no path to run every dataset")
return self.source / rel.parts[0]
[docs]
def catalog_path(self, dataset: Path) -> Path:
return Path(dataset) / self.catalog_name
# -- runs ------------------------------------------------------------------
[docs]
def run(self, paths: list[str | Path] | None = None, recursive: bool = True,
datasets: list[str] | None = None) -> list[Report]:
"""Anonymize every dataset, the named *datasets*, or only *paths*."""
if not paths and self.single_file is not None:
paths = [self.single_file]
if paths:
by_dataset: dict[Path, list[Path]] = {}
for p in paths:
p = Path(p).resolve()
if not p.exists():
raise FileNotFoundError(f"Not found: {p}")
by_dataset.setdefault(self.dataset_of(p), []).append(p)
return [self.run_dataset(d, scope=None if ps == [d] else ps, recursive=recursive)
for d, ps in by_dataset.items()]
targets = self.datasets()
if datasets:
if not self.multi:
raise ValueError("Dataset names need datasets=True (--datasets)")
names = set(datasets)
missing = names - {d.name for d in targets}
if missing:
raise FileNotFoundError(f"Datasets not found in {self.source}: {sorted(missing)}")
targets = [d for d in targets if d.name in names]
return [self.run_dataset(d) for d in targets]
[docs]
def run_dataset(self, dataset: Path, scope: list[Path] | None = None,
recursive: bool = True) -> Report:
dataset = Path(dataset).resolve()
report = Report(dataset=dataset.name)
if self.dry_run:
return self._run_locked(dataset, scope, recursive, report)
with _DatasetLock(dataset, self.lock_stale):
return self._run_locked(dataset, scope, recursive, report)
# -- scan ------------------------------------------------------------------
def _raw_roots(self, dataset: Path, scope: list[Path] | None, recursive: bool):
"""Yield (folder to list, recursive, root of the patient folders)."""
out_root = dataset / self.out_dir
if self.patients_dir_name is None:
# Every file of the dataset is read, patients follow their IDs
for target in scope or [dataset]:
if target == out_root or out_root in target.parents:
raise ValueError(f"{target} is inside the anonymized output folder")
yield target, recursive if scope else True, dataset
return
if scope is None:
for root, dirs, _ in os.walk(dataset):
root_path = Path(root)
if root_path == dataset:
dirs[:] = [d for d in dirs if d != self.out_dir]
dirs[:] = sorted(d for d in dirs if not _hidden(d))
if root_path.name == self.patients_dir_name and root_path != dataset:
dirs[:] = []
yield root_path, True, root_path
return
for target in scope:
if target == out_root or out_root in target.parents:
raise ValueError(f"{target} is inside the anonymized output folder")
raw_root = next((p for p in [target, *target.parents]
if p.name == self.patients_dir_name and dataset in p.parents), None)
if raw_root is not None:
yield target, recursive, raw_root
continue
if target.is_file():
raise ValueError(f"{target} is not inside a {self.patients_dir_name} folder")
if not recursive:
raise ValueError(f"{target} is not inside a {self.patients_dir_name} folder "
"(use recursion to look for one below it)")
for root, dirs, _ in os.walk(target):
root_path = Path(root)
dirs[:] = sorted(d for d in dirs if not _hidden(d)
and not (root_path == dataset and d == self.out_dir))
if root_path.name == self.patients_dir_name:
dirs[:] = []
yield root_path, True, root_path
def _list(self, folder: Path, recursive: bool,
skip: Path | None = None) -> list[tuple[Path, list[os.DirEntry]]]:
"""Files of *folder* grouped by directory (threaded over sub-folders).
*skip* (the dataset's output folder) is never entered.
"""
if folder.is_file():
return [(folder.parent, [e for e in os.scandir(folder.parent) if e.is_file()
and (e.name == folder.name or e.name.startswith(Path(folder.name).stem + "."))])]
def walk(top: Path) -> list[tuple[Path, list[os.DirEntry]]]:
out = []
stack = [top]
while stack:
d = stack.pop()
files, subdirs = [], []
try:
with os.scandir(d) as it:
for e in it:
if e.is_dir(follow_symlinks=False):
if (recursive and not _hidden(e.name) and e.name != _STAGING
and Path(e.path) != skip):
subdirs.append(Path(e.path))
elif e.is_file() and not e.name.startswith("."):
files.append(e)
except OSError as err:
log.warning("cannot list %s: %s", d, err)
continue
out.append((d, files))
stack.extend(sorted(subdirs, reverse=True))
return out
if not recursive:
return walk(folder)
subs = []
files: list[os.DirEntry] = []
try:
with os.scandir(folder) as it:
for e in it:
if (e.is_dir(follow_symlinks=False) and not _hidden(e.name)
and Path(e.path) != skip):
subs.append(Path(e.path))
elif e.is_file() and not e.name.startswith("."):
files.append(e)
except OSError as err:
log.warning("cannot list %s: %s", folder, err)
return []
result = [(folder, files)]
with ThreadPoolExecutor(self.threads) as pool:
for part in pool.map(walk, sorted(subs)):
result.extend(part)
return result
def _detect(self, path: Path) -> Handler | None:
try:
with open(path, "rb") as f:
header = f.read(_SNIFF)
except OSError:
return None
for parser in self._parsers:
try:
if parser.can_parse(path, header):
return HANDLERS[parser.__name__]
except Exception: # noqa: BLE001 - a sniffer failing means "not this format"
continue
return None
def _scan(self, dataset: Path, scope, recursive, catalog: Catalog,
report: Report) -> tuple[dict[str, Entry], list[tuple[Path, bool]]]:
entries: dict[str, Entry] = {}
scanned: list[tuple[Path, bool]] = []
now = time.time()
for folder, rec, raw_root in self._raw_roots(dataset, scope, recursive):
scanned.append((folder, rec, folder.is_file()))
for directory, files in self._list(folder, rec, skip=dataset / self.out_dir):
names = {e.name for e in files}
for e in files:
path = Path(e.path)
rel = path.relative_to(dataset).as_posix()
if path.parent == dataset and path.name in (
self.catalog_name, STATS_NAME, _LOCK):
continue
st = e.stat()
if self.settle and now - st.st_mtime < self.settle:
report.waiting += 1
continue
old = catalog.get(rel)
if (old and old["status"] in ACTIVE and old["raw_size"] == str(st.st_size)
and old["raw_mtime_ns"] == str(st.st_mtime_ns)
and not old["companions"]):
entries[rel] = Entry(path, rel,
self._patient_folder(dataset, raw_root, path), [],
st.st_size, st.st_mtime_ns, None)
continue
handler = self._detect(path)
if handler is None:
report.skipped_unsupported += 1
continue
if isinstance(handler, WFDBHandler) and path.suffix.lower() != ".hea":
if path.stem + ".hea" in names:
continue # part of a record, handled with its header
report.skipped_unsupported += 1
continue
companions = handler.companions(path)
size = st.st_size + sum(c.stat().st_size for c in companions)
mtime = max([st.st_mtime_ns] + [c.stat().st_mtime_ns for c in companions])
entries[rel] = Entry(path, rel, self._patient_folder(dataset, raw_root, path),
companions, size, mtime, handler)
return entries, scanned
def _patient_folder(self, dataset: Path, raw_root: Path, path: Path) -> str:
"""Patient folder of *path*: the first folder under *raw_root* (the
``patients_dir_name`` folder, or the dataset). "" when patients are
not grouped by folder or *path* is directly in *raw_root*."""
if not self.by_folder:
return ""
rel = path.relative_to(raw_root)
if len(rel.parts) > 1:
return (raw_root / rel.parts[0]).relative_to(dataset).as_posix()
return ""
# -- stats -------------------------------------------------------------------
[docs]
def stats(self, dataset: Path) -> list[Stat]:
"""Counts of the raw files and of the anonymized files, each read
from disk (see :mod:`ecgdatakit.anonymize._stats`)."""
return self.stats_and_empty_folders(dataset)[0]
[docs]
def stats_and_empty_folders(self, dataset: Path) -> tuple[list[Stat], list[str]]:
""":meth:`stats`, and the raw patient folders holding no ECG file
(nothing is copied for them), relative to the dataset."""
dataset = Path(dataset)
scope = [self.single_file] if self.single_file is not None else None
raw_roots = [(folder, rec, root) for folder, rec, root
in self._raw_roots(dataset, scope, True)]
out = dataset / self.out_dir
anon_roots = []
for _, _, root in raw_roots:
anon_root = out / root.relative_to(dataset)
if anon_root.is_dir() and all(r != anon_root for _, _, r in anon_roots):
anon_roots.append((anon_root, True, anon_root))
raw = self._records(dataset, raw_roots, skip=out)
anon = self._records(out, anon_roots, skip=None)
raw_dirs = self._patient_dirs(dataset, raw_roots, out)
anon_dirs = self._patient_dirs(out, anon_roots, None)
stats = compare(raw, anon, grouped=self.by_folder, renamed=self.folders,
raw_dirs=raw_dirs, anon_dirs=anon_dirs)
empty = sorted(raw_dirs - {r.patient_folder for r in raw})
return stats, empty
def _patient_dirs(self, base: Path, roots, skip: Path | None) -> set[str]:
"""Every patient folder on disk (the folders directly under the
roots), with or without ECG files, relative to *base*."""
if not self.by_folder:
return set()
dirs: set[str] = set()
for _, _, root in roots:
if not root.is_dir():
continue
for e in os.scandir(root):
if (e.is_dir(follow_symlinks=False) and not _hidden(e.name)
and e.name != _STAGING and Path(e.path) != skip):
dirs.add(Path(e.path).relative_to(base).as_posix())
return dirs
def _records(self, base: Path, roots, skip: Path | None) -> list[FileRecord]:
"""ECG files under *roots*, with their patient folder and the
identity values read in them."""
found: list[tuple[Path, str]] = []
for folder, rec, root in roots:
for _, files in self._list(folder, rec, skip=skip):
for e in files:
path = Path(e.path)
if path.parent == base and path.name in (
self.catalog_name, STATS_NAME, _LOCK):
continue
found.append((path, self._patient_folder(base, root, path)))
def read(item: tuple[Path, str]) -> FileRecord | None:
path, patient_folder = item
handler = self._detect(path)
if handler is None:
return None
if isinstance(handler, WFDBHandler) and path.suffix.lower() != ".hea":
return None
try:
identity = handler.identity(path)
except Exception: # noqa: BLE001 - an unreadable file is counted without values
identity = Identity()
return FileRecord(path.relative_to(base).as_posix(), patient_folder,
tuple(identity.patient_ids), tuple(identity.ecg_ids),
_sha256([path]))
with ThreadPoolExecutor(self.threads) as pool:
return [r for r in pool.map(read, found) if r is not None]
# -- processing --------------------------------------------------------------
def _run_locked(self, dataset: Path, scope, recursive, report: Report) -> Report:
catalog = Catalog(self.catalog_path(dataset))
entries, scanned = self._scan(dataset, scope, recursive, catalog, report)
self._check_same_grouping(dataset, catalog, entries)
report.found = len(entries)
state = _State(catalog)
work = []
for rel, entry in entries.items():
old = catalog.get(rel)
if entry.handler is None and old is not None:
# Unchanged on disk; re-done only when its output disappeared
# or the folder mode changed
if (all((dataset / p).exists() for p in _outputs(old))
and old["folder_anonymized"] == self._folder_flag()):
report.unchanged += 1
continue
entry.handler = self._detect(entry.path)
if entry.handler is None:
report.skipped_unsupported += 1
continue
entry.companions = entry.handler.companions(entry.path)
work.append(entry)
done = 0
with ThreadPoolExecutor(self.threads) as pool:
futures = {pool.submit(self._process, dataset, e, state): e for e in work}
for future in as_completed(futures):
entry = futures[future]
try:
row = future.result()
except Exception as e: # noqa: BLE001 - one bad file never stops the dataset
log.exception("unexpected error on %s", entry.rel)
row = self._failed_row(dataset, entry, catalog.get(entry.rel),
f"{type(e).__name__}: {e}")
status = row["status"]
if status == "failed":
report.failed += 1
log.warning("failed: %s (%s)", entry.rel, row["message"])
elif status == "unchanged":
report.unchanged += 1
row["status"] = catalog.get(entry.rel)["status"]
else:
setattr(report, status, getattr(report, status) + 1)
log.info("%s: %s -> %s", status, entry.rel, row.get("anonymized_path", ""))
if row.get("risk") == "TRUE":
report.risks += 1
log.info("risk: %s (%s)", entry.rel, row.get("risk_reason", ""))
if not self.dry_run:
catalog.put(row)
done += 1
if done % _SAVE_EVERY == 0:
catalog.save()
if not self.dry_run:
report.deleted = self._deleted(dataset, entries, scanned, catalog)
_mark_duplicates(catalog)
catalog.save()
report.errors = self.check(dataset, catalog, entries if scope is None else None)
self._count(dataset, catalog, entries, report)
report.stats, report.empty_patient_folders = self.stats_and_empty_folders(dataset)
write_stats(dataset / STATS_NAME, report.stats)
for error in report.errors:
log.error("%s: %s", dataset.name, error)
return report
def _process(self, dataset: Path, entry: Entry, state: _State) -> dict[str, str]:
catalog = state.catalog
old = catalog.get(entry.rel)
handler = entry.handler
files = [entry.path, *entry.companions]
sha = _sha256(files)
now = _now()
if (old and old["status"] in ACTIVE and old["raw_sha256"] == sha
and old["folder_anonymized"] == self._folder_flag()
and all((dataset / p).exists() for p in _outputs(old))):
row = dict(old, raw_size=str(entry.size), raw_mtime_ns=str(entry.mtime_ns))
row["status"] = "unchanged"
return row
identity = handler.identity(entry.path)
# The patient ID field may hold something else than a patient ID (a
# date, a study code): it is replaced only with patient folder
# grouping. The patient folder then says who the patient is, so the
# field is replaced even when it does not match the folder name (a
# typing error, another patient); only a date is kept. Values that do
# not match are reported as risks for review.
folder = Path(entry.patient_folder).name if entry.patient_folder else ""
file_ids = list(identity.patient_ids)
kept_ids = [v for v in identity.patient_ids
if not self.by_folder or not folder or looks_like_date(v)]
unmatched = [v for v in identity.patient_ids
if v not in kept_ids and not matches_folder(v, folder)]
identity.patient_ids = [v for v in identity.patient_ids if v not in kept_ids]
# Only a patient folder groups files; a file outside one is its own
# patient (an ID read in the file may be wrong, a date for example)
key = _patient_key(entry)
with state.lock:
patient_code = state.patient_codes.get(key)
if patient_code is None:
patient_code = new_code(state.used)
state.patient_codes[key] = patient_code
ecg_ids = SEPARATOR.join(identity.ecg_ids)
# Files with the same content are the same ECG: one ECG pseudonym
if sha in state.ecg_by_sha:
ecg_code = state.ecg_by_sha[sha]
elif old and old["anonymized_ecg_id"] and old["original_ecg_id"] == ecg_ids:
ecg_code = old["anonymized_ecg_id"]
else:
ecg_code = new_ecg_code(state.used)
state.ecg_by_sha.setdefault(sha, ecg_code)
folder_names = [Path(entry.patient_folder).name] if entry.patient_folder else []
# With anonymized patient folders, the folder name identifies the
# patient: it is replaced wherever it appears in the file and its name
folder_ids = folder_names if self.folders else []
risks: list[str] = []
if self.by_folder:
for v in kept_ids:
if looks_like_date(v):
risks.append(f"patient ID field holds a date ({v}), kept as is")
else:
risks.append(f"patient ID field ({v}) kept as is: the file is not in a "
f"patient folder")
for v in unmatched:
risks.append(f"patient ID field ({v}) does not match the patient folder "
f"name, replaced")
# The anonymized file gets a new name: nothing of the raw name is
# kept, since a name may hold identity in any form
if isinstance(handler, WFDBHandler) and handler.is_multi_segment(entry.path):
new_name = entry.path.name
risks.append("multi-segment WFDB record name kept as is (it may hold identity)")
else:
new_name = anonymized_file_name(entry.path.name, patient_code, ecg_code,
wfdb=isinstance(handler, WFDBHandler))
rel_dir = self._output_dir(entry, patient_code)
out_dir = dataset / self.out_dir / rel_dir
with state.lock:
new_name = state.reserve((self.out_dir / rel_dir / new_name).as_posix(), entry.rel,
new_name, ecg_code)
out_path = out_dir / new_name
replacer = Replacer(identity, patient_code, ecg_code, extra_ids=folder_ids)
codes = Codes(patient_code, ecg_code, frozenset(kept_ids))
rewrite = handler.rewrite(entry.path, out_path, identity, codes, replacer)
risks += rewrite.notes
row = {
"raw_path": entry.rel,
"patient_folder": key,
"format": handler.parser,
"anonymized_patient_id": patient_code,
"anonymized_ecg_id": ecg_code,
# With grouping the patient is known by its folder, whatever the
# file holds; the file's own ID values are in file_patient_id
"original_patient_id": folder if self.by_folder and folder
else SEPARATOR.join(identity.patient_ids),
"file_patient_id": SEPARATOR.join(file_ids),
"original_last_name": SEPARATOR.join(identity.last_names),
"original_first_name": SEPARATOR.join(identity.first_names),
"original_ecg_id": ecg_ids,
"original_file_name": entry.path.name,
"anonymized_file_name": new_name,
"folder_anonymized": self._folder_flag(),
"companions": SEPARATOR.join(
(Path(self.out_dir) / rel_dir / p.name).as_posix()
for p in rewrite.outputs if p != out_path),
"raw_size": str(entry.size),
"raw_mtime_ns": str(entry.mtime_ns),
"raw_sha256": sha,
"detected_at": (old or {}).get("detected_at") or now,
"tool_version": __version__,
"anonymized_path": (Path(self.out_dir) / rel_dir / new_name).as_posix(),
}
if self.dry_run:
row.update(status="anonymized" if not identity.is_empty else "copied",
risk="TRUE" if risks else "FALSE", risk_reason=SEPARATOR.join(risks))
return row
# Duplicates share their ECG pseudonym: the staging folder is unique
staging = out_dir / _STAGING / uuid.uuid4().hex
try:
staged = self._stage(rewrite.outputs, staging)
verify(entry.path, staged[out_path], replacer, patient_code, kept=kept_ids)
values = handler.free_text(_head(rewrite.outputs[out_path]))
left = sum(1 for v in values if replacer.replace_value(v, is_data(v)) != v)
if left:
risks.append(f"{left} field value(s) of the anonymized file still hold identity text")
if old:
for p in _outputs(old):
target = dataset / p
if target.exists() and target not in staged:
target.unlink()
_remove_empty_up_to(target.parent, dataset / self.out_dir)
for final, temp in staged.items():
os.replace(temp, final)
row["anonymized_sha256"] = _sha256([out_path])
except VerificationError as e:
return self._failed_row(dataset, entry, old, f"verification failed: {e}", row)
finally:
shutil.rmtree(staging, ignore_errors=True)
_remove_empty(staging.parent)
if old and old["status"] in ACTIVE and old["raw_sha256"] == sha:
# Same raw file, only its output moved (folder mode changed)
status = old["status"]
row["last_change_at"] = old.get("last_change_at", "")
elif old and old["status"] in ACTIVE | {"deleted", "failed"}:
status = "changed"
row["last_change_at"] = now
else:
status = "anonymized" if not identity.is_empty else "copied"
row["last_change_at"] = (old or {}).get("last_change_at", "")
row.update(status=status, anonymized_at=now, risk="TRUE" if risks else "FALSE",
risk_reason=SEPARATOR.join(dict.fromkeys(risks)), message="")
return row
def _folder_flag(self) -> str:
return "TRUE" if self.folders else "FALSE"
def _output_dir(self, entry: Entry, patient_code: str) -> Path:
"""Output sub-folder of *entry*: the raw one, with only the patient
folder renamed to the pseudonym when patient folders are anonymized.
Every other folder, sub-folders of the patient folder included,
keeps its name."""
rel_dir = Path(entry.rel).parent
if not self.folders or not entry.patient_folder:
return rel_dir
patient = Path(entry.patient_folder)
return patient.parent / patient_code / rel_dir.relative_to(patient)
@staticmethod
def _stage(outputs: dict, staging: Path) -> dict[Path, Path]:
staging.mkdir(parents=True, exist_ok=True)
staged: dict[Path, Path] = {}
for final, content in outputs.items():
final.parent.mkdir(parents=True, exist_ok=True)
temp = staging / final.name
with open(temp, "wb") as f:
if isinstance(content, Spliced):
f.write(content.head)
with open(content.source, "rb") as src:
src.seek(content.offset)
shutil.copyfileobj(src, f, 1024 * 1024)
else:
f.write(content)
staged[final] = temp
return staged
def _failed_row(self, dataset: Path, entry: Entry, old, message: str,
row: dict | None = None) -> dict:
row = dict(row or old or {"raw_path": entry.rel, "patient_folder": entry.patient_folder})
row.update(status="failed", message=message, raw_size=str(entry.size),
raw_mtime_ns=str(entry.mtime_ns), tool_version=__version__,
original_file_name=entry.path.name, anonymized_path="", anonymized_file_name="",
companions="")
row.setdefault("detected_at", _now())
if old:
# A previous anonymized copy of an older version is removed
for p in _outputs(old):
(dataset / p).unlink(missing_ok=True)
return row
def _deleted(self, dataset: Path, entries: dict[str, Entry], scanned, catalog: Catalog) -> int:
count = 0
now = _now()
for row in list(catalog.rows.values()):
if row["status"] == "deleted" or row["raw_path"] in entries:
continue
raw = dataset / row["raw_path"]
if not _in_scope(raw, scanned) or raw.exists():
continue
for p in _outputs(row):
(dataset / p).unlink(missing_ok=True)
_remove_empty_up_to((dataset / p).parent, dataset / self.out_dir)
row.update(status="deleted", last_change_at=now, anonymized_path="",
anonymized_sha256="", companions="")
catalog.put(row)
count += 1
return count
# -- consistency -------------------------------------------------------------
def _check_same_grouping(self, dataset: Path, catalog: Catalog,
entries: dict[str, Entry]) -> None:
"""Refuse to continue a catalog that grouped patients differently.
Files are grouped by patient folder (``patients_dir_name``,
``anonymize_patient_folders``); a file outside a patient folder is
its own patient. Continuing a catalog written with other patient
folders, or by an older version that grouped files by patient ID,
would change pseudonyms.
"""
for rel, entry in entries.items():
old = catalog.get(rel)
if old is None or not old["patient_folder"]:
continue
if old["patient_folder"] == _patient_key(entry):
continue
if old["patient_folder"].startswith(("id:", "name:")):
why = "by an older version that grouped files by patient ID"
else:
why = "with other patient folders (--patients-dir-name, --anonymize-patient-folders)"
raise ValueError(
f"{dataset.name} was anonymized {why}: run it with the same options, or "
f"delete its {self.out_dir} folder and {self.catalog_name} to start again")
@staticmethod
def _count(dataset: Path, catalog: Catalog, entries: dict[str, Entry], report: Report) -> None:
"""Counts of the raw files of this run and of their anonymized side."""
rows = [catalog.get(rel) for rel in entries]
done = [r for r in rows if r and r["status"] in ACTIVE
and (dataset / r["anonymized_path"]).exists()]
report.raw_files = len(entries)
report.anonymized_files = len(done)
report.ecg_codes = len({r["anonymized_ecg_id"] for r in done})
report.patients = len({r["patient_folder"] for r in done})
report.patient_codes = len({r["anonymized_patient_id"] for r in done})
[docs]
def check(self, dataset: Path, catalog: Catalog,
entries: dict[str, Entry] | None = None) -> list[str]:
"""Collision and count checks between raw and anonymized files."""
errors: list[str] = []
rows = catalog.active()
by_folder: dict[str, set[str]] = {}
by_code: dict[str, set[str]] = {}
for r in rows:
by_folder.setdefault(r["patient_folder"], set()).add(r["anonymized_patient_id"])
by_code.setdefault(r["anonymized_patient_id"], set()).add(r["patient_folder"])
for folder, codes in by_folder.items():
if len(codes) > 1:
errors.append(f"patient folder {folder!r} has {len(codes)} pseudonyms")
for code, folders in by_code.items():
if len(folders) > 1:
errors.append(f"pseudonym {code} is shared by {len(folders)} patient folders")
if len(by_folder) != len(by_code):
errors.append(f"{len(by_folder)} patients but {len(by_code)} pseudonyms")
contents: dict[str, set[str]] = {}
for r in rows:
contents.setdefault(r["anonymized_ecg_id"], set()).add(r["raw_sha256"])
if any(len(v) > 1 for v in contents.values()):
errors.append("an ECG pseudonym is used by files with different content")
outputs = [r["anonymized_path"] for r in rows]
if len(outputs) != len(set(outputs)):
errors.append("two raw files map to the same anonymized file")
missing = [p for r in rows for p in _outputs(r) if not (dataset / p).exists()]
if missing:
errors.append(f"{len(missing)} anonymized file(s) missing on disk")
if entries is not None:
raw_count = len(entries)
active = {r["raw_path"] for r in rows}
failed = sum(1 for r in catalog.rows.values() if r["status"] == "failed"
and r["raw_path"] in entries)
if raw_count != len(active & set(entries)) + failed:
errors.append(f"{raw_count} raw files but {len(active & set(entries))} "
f"anonymized and {failed} failed")
per_raw: dict[str, int] = {}
for e in entries.values():
per_raw[e.patient_folder or ""] = per_raw.get(e.patient_folder or "", 0) + 1
per_anon: dict[str, int] = {}
for r in rows:
if r["raw_path"] in entries and (dataset / r["anonymized_path"]).exists():
folder = entries[r["raw_path"]].patient_folder or ""
per_anon[folder] = per_anon.get(folder, 0) + 1
for folder, n in per_raw.items():
failed_here = sum(1 for r in catalog.rows.values() if r["status"] == "failed"
and r["raw_path"] in entries
and (entries[r["raw_path"]].patient_folder or "") == folder)
if per_anon.get(folder, 0) + failed_here != n:
errors.append(f"patient folder {folder or '(none)'!r}: {n} raw files, "
f"{per_anon.get(folder, 0)} anonymized")
return errors
class _State:
"""Codes and output names shared by the worker threads of one dataset."""
def __init__(self, catalog: Catalog) -> None:
self.catalog = catalog
self.lock = threading.Lock()
self.used = catalog.used_codes()
self.patient_codes = catalog.patient_codes()
self.taken = {r["anonymized_path"]: r["raw_path"] for r in catalog.active()}
self.ecg_by_sha: dict[str, str] = {}
for r in catalog.active():
if r["raw_sha256"] and r["anonymized_ecg_id"]:
self.ecg_by_sha.setdefault(r["raw_sha256"], r["anonymized_ecg_id"])
def reserve(self, out_rel: str, raw_rel: str, name: str, ecg_code: str) -> str:
"""*name*, or ``<stem>_2``, ``<stem>_3``... when another raw file
already has it (two copies of the same ECG in one folder)."""
stem, ext = split_name(name)
n = 1
while self.taken.get(out_rel) not in (None, raw_rel):
n += 1
name = f"{stem}_{n}{ext}"
out_rel = str(Path(out_rel).parent / name)
self.taken[out_rel] = raw_rel
return name
def _patient_key(entry: Entry) -> str:
"""What groups the patient's files: the patient folder, or the file
itself when it is not in a patient folder."""
return entry.patient_folder or f"file:{entry.rel}"
def _outputs(row: dict) -> list[str]:
paths = [row.get("anonymized_path", "")]
paths += [p for p in (row.get("companions") or "").split(SEPARATOR) if p]
return [p for p in paths if p]
def _in_scope(path: Path, scanned: list[tuple[Path, bool, bool]]) -> bool:
"""True when *path* was in a folder (or is the file) listed by this run."""
for target, recursive, is_file in scanned:
if is_file:
if path == target:
return True
elif path.parent == target or recursive and target in path.parents:
return True
return False
def _mark_duplicates(catalog: Catalog) -> None:
"""Fill the ``duplicate`` column: the raw paths of the other files with
the same content, for every file still on disk."""
with catalog.lock:
by_sha: dict[str, list[str]] = {}
for row in catalog.rows.values():
if row["status"] != "deleted" and row["raw_sha256"]:
by_sha.setdefault(row["raw_sha256"], []).append(row["raw_path"])
for row in catalog.rows.values():
same = by_sha.get(row["raw_sha256"], []) if row["status"] != "deleted" else []
row["duplicate"] = SEPARATOR.join(sorted(p for p in same if p != row["raw_path"]))
def _sha256(paths: list[Path]) -> str:
digest = hashlib.sha256()
for p in paths:
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _head(content) -> bytes:
return content.head if isinstance(content, Spliced) else content
def _remove_empty_up_to(folder: Path, stop: Path) -> None:
"""Remove *folder* and its parents while they are empty, never *stop*."""
while folder != stop and stop in folder.parents:
try:
folder.rmdir()
except OSError:
return
folder = folder.parent
def _remove_empty(folder: Path) -> None:
try:
folder.rmdir()
except OSError:
pass