Source code for ecgdatakit.anonymize._catalog

"""CSV catalog of a dataset.

One row per raw file: original and pseudonymized values, paths, hashes,
status and dates. The catalog is the re-identification key of the dataset.
"""

from __future__ import annotations

import csv
import os
import threading
from pathlib import Path

COLUMNS = [
    "status",
    "risk",
    "risk_reason",
    "raw_path",
    "anonymized_path",
    "patient_folder",
    "format",
    "original_patient_id",
    "anonymized_patient_id",
    "file_patient_id",
    "original_ecg_id",
    "anonymized_ecg_id",
    "original_last_name",
    "original_first_name",
    "original_file_name",
    "anonymized_file_name",
    "folder_anonymized",
    "companions",
    "duplicate",
    "raw_size",
    "raw_mtime_ns",
    "raw_sha256",
    "anonymized_sha256",
    "detected_at",
    "anonymized_at",
    "last_change_at",
    "message",
    "tool_version",
]

ACTIVE = {"anonymized", "copied", "changed"}
"""Statuses of rows whose anonymized file exists."""

SEPARATOR = " | "


[docs] class Catalog: """Rows keyed by raw path (relative to the dataset folder). Writes go to a temporary file renamed over the catalog, so the CSV is never left half written. """
[docs] def __init__(self, path: Path) -> None: self.path = Path(path) self.rows: dict[str, dict[str, str]] = {} self.lock = threading.RLock() if self.path.exists(): with open(self.path, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) if "patient_code" in (reader.fieldnames or []): raise ValueError( f"{self.path} was written by an older version (patient_code " f"column): delete it and the ANONYMIZED folder to start again") for row in reader: self.rows[row["raw_path"]] = {c: row.get(c, "") or "" for c in COLUMNS}
[docs] def get(self, raw_path: str) -> dict[str, str] | None: with self.lock: return self.rows.get(raw_path)
[docs] def put(self, row: dict[str, str]) -> None: with self.lock: self.rows[row["raw_path"]] = {c: str(row.get(c, "") or "") for c in COLUMNS}
[docs] def active(self) -> list[dict[str, str]]: with self.lock: return [r for r in self.rows.values() if r["status"] in ACTIVE]
[docs] def used_codes(self) -> set[str]: with self.lock: return {r[c] for r in self.rows.values() for c in ("anonymized_patient_id", "anonymized_ecg_id") if r[c]}
[docs] def patient_codes(self) -> dict[str, str]: """Patient code by patient folder (every row ever written keeps its code).""" with self.lock: return {r["patient_folder"]: r["anonymized_patient_id"] for r in self.rows.values() if r["patient_folder"] and r["anonymized_patient_id"]}
[docs] def save(self) -> None: with self.lock: rows = sorted(self.rows.values(), key=lambda r: r["raw_path"]) tmp = self.path.with_name(self.path.name + ".tmp") with open(tmp, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=COLUMNS) writer.writeheader() writer.writerows(rows) f.flush() os.fsync(f.fileno()) os.replace(tmp, self.path)