nopii

nopii: Detect, transform, and audit PII in your data.

This package provides comprehensive tools for detecting, transforming, and auditing personally identifiable information (PII) across various data formats and sources.

 1"""
 2nopii: Detect, transform, and audit PII in your data.
 3
 4This package provides comprehensive tools for detecting, transforming, and auditing
 5personally identifiable information (PII) across various data formats and sources.
 6"""
 7
 8__version__ = "0.1.4"
 9__author__ = "ay-mich"
10
11
12# Core functionality
13from .core.models import Policy, Rule, Finding, ScanResult, AuditReport
14from .core.scanner import Scanner
15from .core.transform import Transform
16
17# Policy management
18from .policy.loader import load_policy, create_default_policy, save_policy
19
20# Detector and transformer registries
21from .detectors.registry import DetectorRegistry
22from .transforms.registry import TransformRegistry
23
24# SDK - High-level interface
25from .sdk.client import NoPIIClient
26from .sdk.scanner import SDKScanner
27from .sdk.transform import SDKTransform
28from .sdk.policy import SDKPolicy
29
30# Reporting
31from .reporting.generators import (
32    HTMLReportGenerator,
33    MarkdownReportGenerator,
34    JSONReportGenerator,
35)
36from .reporting.coverage import CoverageCalculator
37
38__all__ = [
39    # Core classes
40    "Policy",
41    "Rule",
42    "Finding",
43    "ScanResult",
44    "AuditReport",
45    "Scanner",
46    "Transform",
47    # Policy management
48    "load_policy",
49    "create_default_policy",
50    "save_policy",
51    # Registries
52    "DetectorRegistry",
53    "TransformRegistry",
54    # SDK - High-level interface
55    "NoPIIClient",
56    "SDKScanner",
57    "SDKTransform",
58    "SDKPolicy",
59    # Reporting
60    "HTMLReportGenerator",
61    "MarkdownReportGenerator",
62    "JSONReportGenerator",
63    "CoverageCalculator",
64    # Package info
65    "__version__",
66    "__author__",
67]
68
69
70def main():
71    """Main entry point for the CLI."""
72    print("Hello from nopii!")
@dataclass
class Policy:
 77@dataclass
 78class Policy:
 79    """
 80    Main policy configuration for PII detection and transformation.
 81
 82    Attributes:
 83        name: Policy name for identification
 84        version: Policy schema version
 85        locale_packs: List of locale packs to enable
 86        default_action: Default transformation action
 87        thresholds: Detection and coverage thresholds
 88        reporting: Reporting configuration
 89        secrets: Secret management configuration
 90        rules: List of transformation rules
 91        exceptions: List of policy exceptions
 92        policy_hash: SHA-256 hash of policy content for audit trails
 93    """
 94
 95    name: str = "default_policy"
 96    version: str = "1"
 97    description: Optional[str] = None
 98    locale_packs: List[str] = field(default_factory=lambda: ["generic"])
 99    default_action: str = "mask"
100    thresholds: Dict[str, Any] = field(default_factory=dict)
101    reporting: Dict[str, Any] = field(default_factory=dict)
102    secrets: Dict[str, str] = field(default_factory=dict)
103    rules: List[Rule] = field(default_factory=list)
104    exceptions: List[PolicyException] = field(default_factory=list)
105    policy_hash: Optional[str] = None
106
107    def __post_init__(self) -> None:
108        """Set default values and compute policy hash."""
109        # Set default thresholds
110        default_thresholds = {
111            "min_confidence": 0.65,
112            "fail_on_untransform": False,
113            "coverage_target": 0.85,
114        }
115        for k1, v1 in default_thresholds.items():
116            self.thresholds.setdefault(k1, v1)
117
118        # Set default reporting config
119        default_reporting = {
120            "formats": ["json"],
121            "output_dir": "reports",
122            "store_samples": 3,
123            "include_trends": False,
124        }
125        for k2, v2 in default_reporting.items():
126            self.reporting.setdefault(k2, v2)
127
128        # Set default secrets config
129        default_secrets = {
130            "tokenization_key_env": "REDACT_PII_KEY",
131            "namespace_env": "REDACT_PII_NS",
132        }
133        for k3, v3 in default_secrets.items():
134            self.secrets.setdefault(k3, v3)
135
136        # Compute policy hash if not provided
137        if self.policy_hash is None:
138            self.policy_hash = self._compute_hash()
139
140    def _compute_hash(self) -> str:
141        """Compute SHA-256 hash of policy content for audit trails."""
142        # Create a deterministic string representation
143        content = f"{self.version}|{sorted(self.locale_packs)}|{self.default_action}"
144        content += f"|{sorted(self.thresholds.items())}"
145        content += f"|{len(self.rules)}|{len(self.exceptions)}"
146
147        return hashlib.sha256(content.encode()).hexdigest()[:16]
148
149    def get_rule_for_column(self, column: str) -> Optional[Rule]:
150        """Get the most specific rule for a column."""
151        # First check for column-specific rules
152        for rule in self.rules:
153            if rule.columns and column in rule.columns:
154                return rule
155        return None
156
157    def get_rule_for_type(self, pii_type: str) -> Optional[Rule]:
158        """Get the rule for a specific PII type."""
159        for rule in self.rules:
160            if rule.match == pii_type:
161                return rule
162        return None
163
164    def is_allowed(self, dataset: str, pii_type: str) -> bool:
165        """Check if a PII type is allowed in a specific dataset."""
166        for exception in self.exceptions:
167            if exception.dataset == dataset and pii_type in exception.allow_types:
168                return True
169        return False
170
171    def model_dump(self, exclude: Optional[set] = None) -> Dict[str, Any]:
172        """Convert policy to dictionary for serialization."""
173        exclude = exclude or set()
174
175        result = {}
176        for field_name in [
177            "name",
178            "version",
179            "description",
180            "locale_packs",
181            "default_action",
182            "thresholds",
183            "reporting",
184            "secrets",
185            "rules",
186            "exceptions",
187            "policy_hash",
188        ]:
189            if field_name not in exclude:
190                value = getattr(self, field_name)
191                if field_name == "rules":
192                    result[field_name] = [self._rule_to_dict(rule) for rule in value]
193                elif field_name == "exceptions":
194                    result[field_name] = [self._exception_to_dict(exc) for exc in value]
195                else:
196                    result[field_name] = value
197
198        return result
199
200    def _rule_to_dict(self, rule: Rule) -> Dict[str, Any]:
201        """Convert Rule to dictionary."""
202        out: Dict[str, Any] = {
203            "action": rule.action,
204            "options": rule.options,
205            "override_confidence": rule.override_confidence,
206        }
207
208        # Only include non-None values for match and columns
209        if rule.match is not None:
210            out["match"] = rule.match
211        if rule.columns is not None:
212            out["columns"] = list(rule.columns)
213
214        return out
215
216    def _exception_to_dict(self, exception: PolicyException) -> Dict[str, Any]:
217        """Convert Exception to dictionary."""
218        return {
219            "dataset": exception.dataset,
220            "allow_types": exception.allow_types,
221            "conditions": exception.conditions,
222        }

Main policy configuration for PII detection and transformation.

Attributes: name: Policy name for identification version: Policy schema version locale_packs: List of locale packs to enable default_action: Default transformation action thresholds: Detection and coverage thresholds reporting: Reporting configuration secrets: Secret management configuration rules: List of transformation rules exceptions: List of policy exceptions policy_hash: SHA-256 hash of policy content for audit trails

Policy( name: str = 'default_policy', version: str = '1', description: Optional[str] = None, locale_packs: List[str] = <factory>, default_action: str = 'mask', thresholds: Dict[str, Any] = <factory>, reporting: Dict[str, Any] = <factory>, secrets: Dict[str, str] = <factory>, rules: List[Rule] = <factory>, exceptions: List[nopii.core.models.PolicyException] = <factory>, policy_hash: Optional[str] = None)
name: str = 'default_policy'
version: str = '1'
description: Optional[str] = None
locale_packs: List[str]
default_action: str = 'mask'
thresholds: Dict[str, Any]
reporting: Dict[str, Any]
secrets: Dict[str, str]
rules: List[Rule]
exceptions: List[nopii.core.models.PolicyException]
policy_hash: Optional[str] = None
def get_rule_for_column(self, column: str) -> Optional[Rule]:
149    def get_rule_for_column(self, column: str) -> Optional[Rule]:
150        """Get the most specific rule for a column."""
151        # First check for column-specific rules
152        for rule in self.rules:
153            if rule.columns and column in rule.columns:
154                return rule
155        return None

Get the most specific rule for a column.

def get_rule_for_type(self, pii_type: str) -> Optional[Rule]:
157    def get_rule_for_type(self, pii_type: str) -> Optional[Rule]:
158        """Get the rule for a specific PII type."""
159        for rule in self.rules:
160            if rule.match == pii_type:
161                return rule
162        return None

Get the rule for a specific PII type.

def is_allowed(self, dataset: str, pii_type: str) -> bool:
164    def is_allowed(self, dataset: str, pii_type: str) -> bool:
165        """Check if a PII type is allowed in a specific dataset."""
166        for exception in self.exceptions:
167            if exception.dataset == dataset and pii_type in exception.allow_types:
168                return True
169        return False

Check if a PII type is allowed in a specific dataset.

def model_dump(self, exclude: Optional[set] = None) -> Dict[str, Any]:
171    def model_dump(self, exclude: Optional[set] = None) -> Dict[str, Any]:
172        """Convert policy to dictionary for serialization."""
173        exclude = exclude or set()
174
175        result = {}
176        for field_name in [
177            "name",
178            "version",
179            "description",
180            "locale_packs",
181            "default_action",
182            "thresholds",
183            "reporting",
184            "secrets",
185            "rules",
186            "exceptions",
187            "policy_hash",
188        ]:
189            if field_name not in exclude:
190                value = getattr(self, field_name)
191                if field_name == "rules":
192                    result[field_name] = [self._rule_to_dict(rule) for rule in value]
193                elif field_name == "exceptions":
194                    result[field_name] = [self._exception_to_dict(exc) for exc in value]
195                else:
196                    result[field_name] = value
197
198        return result

Convert policy to dictionary for serialization.

@dataclass
class Rule:
26@dataclass
27class Rule:
28    """
29    A policy rule that defines how to handle specific PII types or columns.
30
31    Attributes:
32        match: PII type to match (e.g., 'email', 'phone')
33        columns: Specific column names to apply this rule to
34        action: Transformation action ('mask', 'hash', 'tokenize', 'redact', 'nullify')
35        options: Action-specific configuration options
36        override_confidence: Override the detector's confidence score
37    """
38
39    action: str = "mask"
40    match: Optional[str] = None
41    columns: Optional[List[str]] = None
42    options: Dict[str, Any] = field(default_factory=dict)
43    override_confidence: Optional[float] = None
44
45    def __post_init__(self) -> None:
46        """Validate rule configuration."""
47        if not self.match and not self.columns:
48            raise ValueError("Rule must specify either 'match' or 'columns'")
49
50        valid_actions = {"mask", "hash", "tokenize", "redact", "nullify"}
51        if self.action not in valid_actions:
52            raise ValueError(
53                f"Invalid action '{self.action}'. Must be one of {valid_actions}"
54            )
55
56        if self.override_confidence is not None:
57            if not 0.0 <= self.override_confidence <= 1.0:
58                raise ValueError("override_confidence must be between 0.0 and 1.0")

A policy rule that defines how to handle specific PII types or columns.

Attributes: match: PII type to match (e.g., 'email', 'phone') columns: Specific column names to apply this rule to action: Transformation action ('mask', 'hash', 'tokenize', 'redact', 'nullify') options: Action-specific configuration options override_confidence: Override the detector's confidence score

Rule( action: str = 'mask', match: Optional[str] = None, columns: Optional[List[str]] = None, options: Dict[str, Any] = <factory>, override_confidence: Optional[float] = None)
action: str = 'mask'
match: Optional[str] = None
columns: Optional[List[str]] = None
options: Dict[str, Any]
override_confidence: Optional[float] = None
@dataclass
class Finding:
225@dataclass
226class Finding:
227    """
228    A detected PII instance with metadata.
229
230    Attributes:
231        type: Type of PII detected (e.g., 'email', 'phone')
232        value: The detected PII value
233        span: Start and end positions in text (for string data)
234        column: Column name where PII was found
235        row_index: Row index where PII was found
236        confidence: Confidence score (0.0 to 1.0)
237        evidence: Explanation of why this was detected as PII
238        transformed_value: Value after transformation (if applicable)
239        action_taken: Transformation action applied
240    """
241
242    type: str
243    value: str
244    span: Tuple[int, int]
245    column: str
246    row_index: int
247    confidence: float
248    evidence: str
249    transformed_value: Optional[str] = None
250    action_taken: Optional[str] = None
251
252    def __post_init__(self) -> None:
253        """Validate finding data."""
254        if not 0.0 <= self.confidence <= 1.0:
255            raise ValueError("Confidence must be between 0.0 and 1.0")
256
257        if self.span[0] > self.span[1]:
258            raise ValueError("Invalid span: start must be <= end")

A detected PII instance with metadata.

Attributes: type: Type of PII detected (e.g., 'email', 'phone') value: The detected PII value span: Start and end positions in text (for string data) column: Column name where PII was found row_index: Row index where PII was found confidence: Confidence score (0.0 to 1.0) evidence: Explanation of why this was detected as PII transformed_value: Value after transformation (if applicable) action_taken: Transformation action applied

Finding( type: str, value: str, span: Tuple[int, int], column: str, row_index: int, confidence: float, evidence: str, transformed_value: Optional[str] = None, action_taken: Optional[str] = None)
type: str
value: str
span: Tuple[int, int]
column: str
row_index: int
confidence: float
evidence: str
transformed_value: Optional[str] = None
action_taken: Optional[str] = None
@dataclass
class ScanResult:
261@dataclass
262class ScanResult:
263    """
264    Results from scanning data for PII.
265
266    Attributes:
267        findings: List of detected PII instances
268        coverage_score: Percentage of PII properly handled (0.0 to 1.0)
269        scan_metadata: Additional metadata about the scan
270        policy_hash: Hash of the policy used for scanning
271        timestamp: When the scan was performed
272        dataset_name: Name of the scanned dataset
273        total_rows: Total number of rows scanned
274        total_columns: Total number of columns scanned
275    """
276
277    findings: List[Finding]
278    coverage_score: float
279    scan_metadata: Dict[str, Any]
280    policy_hash: str
281    timestamp: datetime
282    dataset_name: str = "unknown"
283    total_rows: int = 0
284    total_columns: int = 0
285
286    def __post_init__(self) -> None:
287        """Validate scan result data."""
288        if not 0.0 <= self.coverage_score <= 1.0:
289            raise ValueError("Coverage score must be between 0.0 and 1.0")
290
291    def get_findings_by_type(self) -> Dict[str, List[Finding]]:
292        """Group findings by PII type."""
293        by_type: Dict[str, List[Finding]] = {}
294        for finding in self.findings:
295            by_type.setdefault(finding.type, []).append(finding)
296        return by_type
297
298    def get_findings_by_column(self) -> Dict[str, List[Finding]]:
299        """Group findings by column."""
300        by_column: Dict[str, List[Finding]] = {}
301        for finding in self.findings:
302            by_column.setdefault(finding.column, []).append(finding)
303        return by_column
304
305    def get_summary_stats(self) -> Dict[str, int]:
306        """Get summary statistics about the findings."""
307        by_type = self.get_findings_by_type()
308        return {
309            "total_findings": len(self.findings),
310            "unique_types": len(by_type),
311            "affected_columns": len(self.get_findings_by_column()),
312            "high_confidence": len([f for f in self.findings if f.confidence >= 0.8]),
313            "medium_confidence": len(
314                [f for f in self.findings if 0.5 <= f.confidence < 0.8]
315            ),
316            "low_confidence": len([f for f in self.findings if f.confidence < 0.5]),
317        }

Results from scanning data for PII.

Attributes: findings: List of detected PII instances coverage_score: Percentage of PII properly handled (0.0 to 1.0) scan_metadata: Additional metadata about the scan policy_hash: Hash of the policy used for scanning timestamp: When the scan was performed dataset_name: Name of the scanned dataset total_rows: Total number of rows scanned total_columns: Total number of columns scanned

ScanResult( findings: List[Finding], coverage_score: float, scan_metadata: Dict[str, Any], policy_hash: str, timestamp: datetime.datetime, dataset_name: str = 'unknown', total_rows: int = 0, total_columns: int = 0)
findings: List[Finding]
coverage_score: float
scan_metadata: Dict[str, Any]
policy_hash: str
timestamp: datetime.datetime
dataset_name: str = 'unknown'
total_rows: int = 0
total_columns: int = 0
def get_findings_by_type(self) -> Dict[str, List[Finding]]:
291    def get_findings_by_type(self) -> Dict[str, List[Finding]]:
292        """Group findings by PII type."""
293        by_type: Dict[str, List[Finding]] = {}
294        for finding in self.findings:
295            by_type.setdefault(finding.type, []).append(finding)
296        return by_type

Group findings by PII type.

def get_findings_by_column(self) -> Dict[str, List[Finding]]:
298    def get_findings_by_column(self) -> Dict[str, List[Finding]]:
299        """Group findings by column."""
300        by_column: Dict[str, List[Finding]] = {}
301        for finding in self.findings:
302            by_column.setdefault(finding.column, []).append(finding)
303        return by_column

Group findings by column.

def get_summary_stats(self) -> Dict[str, int]:
305    def get_summary_stats(self) -> Dict[str, int]:
306        """Get summary statistics about the findings."""
307        by_type = self.get_findings_by_type()
308        return {
309            "total_findings": len(self.findings),
310            "unique_types": len(by_type),
311            "affected_columns": len(self.get_findings_by_column()),
312            "high_confidence": len([f for f in self.findings if f.confidence >= 0.8]),
313            "medium_confidence": len(
314                [f for f in self.findings if 0.5 <= f.confidence < 0.8]
315            ),
316            "low_confidence": len([f for f in self.findings if f.confidence < 0.5]),
317        }

Get summary statistics about the findings.

@dataclass
class AuditReport:
320@dataclass
321class AuditReport:
322    """
323    Comprehensive audit report for compliance and governance.
324
325    Attributes:
326        job_name: Name of the job/process that generated this report
327        timestamp: When the report was generated
328        policy_hash: Hash of the policy used
329        coverage_score: Overall PII coverage score
330        residual_risk: Calculated residual risk score
331        summary_stats: Summary statistics
332        findings_by_type: Findings grouped by PII type
333        performance_metrics: Performance and timing metrics
334        samples: Sample transformed values for review
335        scan_result: The underlying scan result
336    """
337
338    job_name: str
339    timestamp: datetime
340    policy_hash: str
341    coverage_score: float
342    residual_risk: float
343    summary_stats: Dict[str, int]
344    findings_by_type: Dict[str, List[Finding]]
345    performance_metrics: Dict[str, float]
346    samples: Dict[str, List[str]]
347    scan_result: ScanResult
348    metadata: Dict[str, Any] = field(default_factory=dict)
349
350    def __post_init__(self) -> None:
351        """Validate audit report data."""
352        if not 0.0 <= self.coverage_score <= 1.0:
353            raise ValueError("Coverage score must be between 0.0 and 1.0")
354
355        if not 0.0 <= self.residual_risk <= 1.0:
356            raise ValueError("Residual risk must be between 0.0 and 1.0")
357
358    def get_coverage_by_type(self) -> Dict[str, float]:
359        """Calculate coverage score by PII type."""
360        coverage_by_type = {}
361        for pii_type, findings in self.findings_by_type.items():
362            protected = len([f for f in findings if f.action_taken])
363            total = len(findings)
364            coverage_by_type[pii_type] = protected / total if total > 0 else 1.0
365        return coverage_by_type
366
367    def get_risk_factors(self) -> Dict[str, float]:
368        """Calculate risk factors contributing to residual risk."""
369        total_findings = len(self.scan_result.findings)
370        if total_findings == 0:
371            return {"no_pii_detected": 0.0}
372
373        unprotected = len([f for f in self.scan_result.findings if not f.action_taken])
374        low_confidence = len(
375            [f for f in self.scan_result.findings if f.confidence < 0.5]
376        )
377
378        return {
379            "unprotected_ratio": unprotected / total_findings,
380            "low_confidence_ratio": low_confidence / total_findings,
381            "coverage_gap": 1.0 - self.coverage_score,
382        }
383
384    def passes_threshold(self, threshold: float) -> bool:
385        """Check if coverage score meets the specified threshold."""
386        return self.coverage_score >= threshold
387
388    def model_dump(self) -> Dict[str, Any]:
389        """Return a serializable dictionary representation of the report."""
390        return asdict(self)
391
392    @property
393    def findings(self) -> List[Finding]:
394        """Compatibility property to access findings directly from the report."""
395        return self.scan_result.findings

Comprehensive audit report for compliance and governance.

Attributes: job_name: Name of the job/process that generated this report timestamp: When the report was generated policy_hash: Hash of the policy used coverage_score: Overall PII coverage score residual_risk: Calculated residual risk score summary_stats: Summary statistics findings_by_type: Findings grouped by PII type performance_metrics: Performance and timing metrics samples: Sample transformed values for review scan_result: The underlying scan result

AuditReport( job_name: str, timestamp: datetime.datetime, policy_hash: str, coverage_score: float, residual_risk: float, summary_stats: Dict[str, int], findings_by_type: Dict[str, List[Finding]], performance_metrics: Dict[str, float], samples: Dict[str, List[str]], scan_result: ScanResult, metadata: Dict[str, Any] = <factory>)
job_name: str
timestamp: datetime.datetime
policy_hash: str
coverage_score: float
residual_risk: float
summary_stats: Dict[str, int]
findings_by_type: Dict[str, List[Finding]]
performance_metrics: Dict[str, float]
samples: Dict[str, List[str]]
scan_result: ScanResult
metadata: Dict[str, Any]
def get_coverage_by_type(self) -> Dict[str, float]:
358    def get_coverage_by_type(self) -> Dict[str, float]:
359        """Calculate coverage score by PII type."""
360        coverage_by_type = {}
361        for pii_type, findings in self.findings_by_type.items():
362            protected = len([f for f in findings if f.action_taken])
363            total = len(findings)
364            coverage_by_type[pii_type] = protected / total if total > 0 else 1.0
365        return coverage_by_type

Calculate coverage score by PII type.

def get_risk_factors(self) -> Dict[str, float]:
367    def get_risk_factors(self) -> Dict[str, float]:
368        """Calculate risk factors contributing to residual risk."""
369        total_findings = len(self.scan_result.findings)
370        if total_findings == 0:
371            return {"no_pii_detected": 0.0}
372
373        unprotected = len([f for f in self.scan_result.findings if not f.action_taken])
374        low_confidence = len(
375            [f for f in self.scan_result.findings if f.confidence < 0.5]
376        )
377
378        return {
379            "unprotected_ratio": unprotected / total_findings,
380            "low_confidence_ratio": low_confidence / total_findings,
381            "coverage_gap": 1.0 - self.coverage_score,
382        }

Calculate risk factors contributing to residual risk.

def passes_threshold(self, threshold: float) -> bool:
384    def passes_threshold(self, threshold: float) -> bool:
385        """Check if coverage score meets the specified threshold."""
386        return self.coverage_score >= threshold

Check if coverage score meets the specified threshold.

def model_dump(self) -> Dict[str, Any]:
388    def model_dump(self) -> Dict[str, Any]:
389        """Return a serializable dictionary representation of the report."""
390        return asdict(self)

Return a serializable dictionary representation of the report.

findings: List[Finding]
392    @property
393    def findings(self) -> List[Finding]:
394        """Compatibility property to access findings directly from the report."""
395        return self.scan_result.findings

Compatibility property to access findings directly from the report.

class Scanner:
 23class Scanner:
 24    """
 25    Core PII scanner that detects sensitive information in various data formats.
 26
 27    The scanner uses registered detectors to find PII and applies policy rules
 28    to determine confidence thresholds and detection strategies.
 29    """
 30
 31    def __init__(self, policy: Policy) -> None:
 32        """
 33        Initialize scanner with a policy.
 34
 35        Args:
 36            policy: Policy configuration for detection rules and thresholds
 37        """
 38        self.policy = policy
 39        self.detector_registry = DetectorRegistry()
 40        self._load_locale_packs()
 41
 42    def _load_locale_packs(self) -> None:
 43        """Load detectors for enabled locale packs."""
 44        for locale_pack in self.policy.locale_packs:
 45            self.detector_registry.load_locale_pack(locale_pack)
 46
 47    def scan_dataframe(
 48        self,
 49        df: Any,
 50        dataset_name: str = "unknown",
 51        confidence_threshold: Optional[float] = None,
 52    ) -> ScanResult:
 53        """
 54        Scan a pandas DataFrame for PII.
 55
 56        Args:
 57            df: DataFrame to scan
 58            dataset_name: Name of the dataset for reporting
 59            confidence_threshold: Override policy confidence threshold
 60
 61        Returns:
 62            ScanResult with detected PII and metadata
 63        """
 64        start_time = time.time()
 65
 66        threshold = self._resolve_threshold(confidence_threshold)
 67        findings: List[Finding] = []
 68
 69        if pd is None:
 70            raise ImportError(
 71                "pandas is required for scan_dataframe; install pandas to use this feature"
 72            )
 73
 74        # Scan each column
 75        for column in df.columns:
 76            column_findings = self._scan_column(df, column, threshold)
 77            findings.extend(column_findings)
 78
 79        # Calculate coverage score
 80        coverage_score = self._calculate_coverage_score(findings, dataset_name)
 81
 82        # Create scan metadata
 83        scan_metadata = {
 84            "scan_duration": time.time() - start_time,
 85            "confidence_threshold": threshold,
 86            "detectors_used": list(self.detector_registry.get_detector_names()),
 87            "locale_packs": self.policy.locale_packs,
 88        }
 89
 90        return ScanResult(
 91            findings=findings,
 92            coverage_score=coverage_score,
 93            scan_metadata=scan_metadata,
 94            policy_hash=self.policy.policy_hash or "",
 95            timestamp=datetime.now(),
 96            dataset_name=dataset_name,
 97            total_rows=len(df),
 98            total_columns=len(df.columns),
 99        )
100
101    def _scan_column(self, df: Any, column: str, threshold: float) -> List[Finding]:
102        """Scan a single column for PII."""
103        findings: List[Finding] = []
104
105        # Check if column has a specific rule
106        column_rule = self.policy.get_rule_for_column(column)
107        if column_rule and column_rule.override_confidence is not None:
108            threshold = column_rule.override_confidence
109
110        # Convert column to string and scan each value
111        series = df[column].astype(str)
112
113        for row_idx, value in enumerate(series):
114            if pd.isna(value) or value == "nan" or value.strip() == "":
115                continue
116
117            findings.extend(
118                self._scan_value(
119                    value, threshold, column, row_idx, {"column_name": column}
120                )
121            )
122
123        return findings
124
125    def _resolve_threshold(self, confidence_threshold: Optional[float]) -> float:
126        """Use the policy default only when no override was supplied."""
127        return (
128            self.policy.thresholds["min_confidence"]
129            if confidence_threshold is None
130            else confidence_threshold
131        )
132
133    def _scan_value(
134        self,
135        value: str,
136        threshold: float,
137        column: str,
138        row_index: int,
139        context: Optional[Dict[str, Any]] = None,
140    ) -> List[Finding]:
141        """Run detectors and normalize their matches for every input format."""
142        findings: List[Finding] = []
143        for detector in self.detector_registry.get_detectors():
144            matches = (
145                detector.find(value)
146                if context is None
147                else detector.find(value, context)
148            )
149            for match in matches:
150                finding = self._finding_from_match(
151                    match, detector.pii_type, detector.name, value, column, row_index
152                )
153                if finding is not None and finding.confidence >= threshold:
154                    findings.append(finding)
155        return findings
156
157    @staticmethod
158    def _finding_from_match(
159        match: Any,
160        pii_type: str,
161        detector_name: str,
162        value: str,
163        column: str,
164        row_index: int,
165    ) -> Optional[Finding]:
166        """Support tuple matches and legacy objects at the detector boundary."""
167        evidence = f"Detected by {detector_name} detector"
168        if isinstance(match, tuple) and len(match) == 3:
169            start, end, confidence = match
170            detected_value = value[start:end]
171            span = (start, end)
172        elif hasattr(match, "confidence"):
173            confidence = match.confidence
174            detected_value = match.value
175            span = match.span
176            evidence = getattr(match, "evidence", evidence)
177        else:
178            return None
179
180        return Finding(
181            type=pii_type,
182            value=detected_value,
183            span=span,
184            column=column,
185            row_index=row_index,
186            confidence=confidence,
187            evidence=evidence,
188        )
189
190    def _calculate_coverage_score(
191        self, findings: List[Finding], dataset_name: str
192    ) -> float:
193        """
194        Calculate PII coverage score based on policy rules.
195
196        Coverage = (Protected PII Items) / (Detected PII Items + Policy-Declared PII Fields)
197        """
198        if not findings:
199            return 1.0  # No PII detected = perfect coverage
200
201        protected_count = sum(
202            self._would_be_protected(finding, dataset_name) for finding in findings
203        )
204        declared_fields = sum(len(rule.columns or []) for rule in self.policy.rules)
205        return (protected_count + declared_fields) / (len(findings) + declared_fields)
206
207    def _would_be_protected(self, finding: Finding, dataset_name: str) -> bool:
208        """Check if a finding would be protected by policy rules."""
209        # Explicitly allowed by exception counts as handled by policy intent
210        if self.policy.is_allowed(dataset_name, finding.type):
211            return True
212
213        # Check if there's a specific rule for this type or column
214        type_rule = self.policy.get_rule_for_type(finding.type)
215        column_rule = self.policy.get_rule_for_column(finding.column)
216
217        return bool(type_rule or column_rule)
218
219    def scan_text(
220        self, text: str, confidence_threshold: Optional[float] = None
221    ) -> List[Finding]:
222        """
223        Scan a text string for PII.
224
225        Args:
226            text: Text to scan
227            confidence_threshold: Override policy confidence threshold
228
229        Returns:
230            List of findings
231        """
232        threshold = self._resolve_threshold(confidence_threshold)
233        return self._scan_value(text, threshold, "text", 0)
234
235    def scan_text_result(
236        self,
237        text: str,
238        dataset_name: str = "text",
239        confidence_threshold: Optional[float] = None,
240    ) -> ScanResult:
241        """Scan text and return a ScanResult object for reporting."""
242        threshold = self._resolve_threshold(confidence_threshold)
243        findings = self.scan_text(text, threshold)
244
245        coverage_score = self._calculate_coverage_score(findings, dataset_name)
246
247        scan_metadata = {
248            "scan_duration": 0.0,
249            "confidence_threshold": threshold,
250            "detectors_used": list(self.detector_registry.get_detector_names()),
251            "locale_packs": self.policy.locale_packs,
252        }
253
254        return ScanResult(
255            findings=findings,
256            coverage_score=coverage_score,
257            scan_metadata=scan_metadata,
258            policy_hash=self.policy.policy_hash or "",
259            timestamp=datetime.now(),
260            dataset_name=dataset_name,
261            total_rows=1,
262            total_columns=1,
263        )
264
265    def scan_file(
266        self,
267        file_path: Union[str, Path],
268        confidence_threshold: Optional[float] = None,
269    ) -> ScanResult:
270        """
271        Stream-scan a text or CSV file without loading it fully into memory.
272
273        Supports:
274        - .txt/.md: line-by-line
275        - .csv: row-by-row via csv module
276
277        For JSON/Parquet and other tabular formats, use SDKScanner (pandas) or
278        CLI load helpers.
279        """
280        start_time = time.time()
281        path = Path(file_path)
282        if not path.exists():
283            raise FileNotFoundError(f"File not found: {path}")
284
285        threshold = self._resolve_threshold(confidence_threshold)
286        findings: List[Finding] = []
287        total_rows = 0
288        total_columns = 1
289        dataset_name = path.stem
290
291        suffix = path.suffix.lower()
292        if suffix in [".txt", ".md"]:
293            with open(path, "r", encoding="utf-8", errors="ignore") as f:
294                for line_idx, line in enumerate(f):
295                    line = line.rstrip("\n")
296                    findings.extend(self._scan_value(line, threshold, "line", line_idx))
297                    total_rows += 1
298            total_columns = 1
299        elif suffix == ".csv":
300            with open(path, "r", encoding="utf-8", newline="") as f:
301                reader = csv.reader(f)
302                headers = next(reader, [])
303                total_columns = len(headers)
304                for row_idx, row in enumerate(reader):
305                    # Track rows processed
306                    total_rows += 1
307                    for col_idx, cell in enumerate(row):
308                        column_name = (
309                            headers[col_idx]
310                            if col_idx < len(headers)
311                            else f"col_{col_idx}"
312                        )
313                        findings.extend(
314                            self._scan_value(
315                                cell,
316                                threshold,
317                                column_name,
318                                row_idx,
319                                {"column_name": column_name},
320                            )
321                        )
322        else:
323            raise ValueError(
324                f"Unsupported file format for streaming scan: {suffix}. "
325                "Use SDKScanner (pandas) for JSON/Parquet."
326            )
327
328        coverage_score = self._calculate_coverage_score(findings, dataset_name)
329
330        scan_metadata = {
331            "scan_duration": time.time() - start_time,
332            "confidence_threshold": threshold,
333            "detectors_used": list(self.detector_registry.get_detector_names()),
334            "locale_packs": self.policy.locale_packs,
335            "file_path": str(path),
336            "streaming": True,
337        }
338
339        return ScanResult(
340            findings=findings,
341            coverage_score=coverage_score,
342            scan_metadata=scan_metadata,
343            policy_hash=self.policy.policy_hash or "",
344            timestamp=datetime.now(),
345            dataset_name=dataset_name,
346            total_rows=total_rows,
347            total_columns=total_columns,
348        )
349
350    def scan_dict(
351        self, data: Dict[str, Any], confidence_threshold: Optional[float] = None
352    ) -> List[Finding]:
353        """
354        Scan a dictionary for PII.
355
356        Args:
357            data: Dictionary to scan
358            confidence_threshold: Override policy confidence threshold
359
360        Returns:
361            List of findings
362        """
363        threshold = self._resolve_threshold(confidence_threshold)
364        findings: List[Finding] = []
365
366        for key, value in data.items():
367            if isinstance(value, str):
368                text_findings = self.scan_text(value, threshold)
369                # Update column name for each finding
370                for finding in text_findings:
371                    finding.column = key
372                    findings.append(finding)
373
374        return findings

Core PII scanner that detects sensitive information in various data formats.

The scanner uses registered detectors to find PII and applies policy rules to determine confidence thresholds and detection strategies.

Scanner(policy: Policy)
31    def __init__(self, policy: Policy) -> None:
32        """
33        Initialize scanner with a policy.
34
35        Args:
36            policy: Policy configuration for detection rules and thresholds
37        """
38        self.policy = policy
39        self.detector_registry = DetectorRegistry()
40        self._load_locale_packs()

Initialize scanner with a policy.

Args: policy: Policy configuration for detection rules and thresholds

policy
detector_registry
def scan_dataframe( self, df: Any, dataset_name: str = 'unknown', confidence_threshold: Optional[float] = None) -> ScanResult:
47    def scan_dataframe(
48        self,
49        df: Any,
50        dataset_name: str = "unknown",
51        confidence_threshold: Optional[float] = None,
52    ) -> ScanResult:
53        """
54        Scan a pandas DataFrame for PII.
55
56        Args:
57            df: DataFrame to scan
58            dataset_name: Name of the dataset for reporting
59            confidence_threshold: Override policy confidence threshold
60
61        Returns:
62            ScanResult with detected PII and metadata
63        """
64        start_time = time.time()
65
66        threshold = self._resolve_threshold(confidence_threshold)
67        findings: List[Finding] = []
68
69        if pd is None:
70            raise ImportError(
71                "pandas is required for scan_dataframe; install pandas to use this feature"
72            )
73
74        # Scan each column
75        for column in df.columns:
76            column_findings = self._scan_column(df, column, threshold)
77            findings.extend(column_findings)
78
79        # Calculate coverage score
80        coverage_score = self._calculate_coverage_score(findings, dataset_name)
81
82        # Create scan metadata
83        scan_metadata = {
84            "scan_duration": time.time() - start_time,
85            "confidence_threshold": threshold,
86            "detectors_used": list(self.detector_registry.get_detector_names()),
87            "locale_packs": self.policy.locale_packs,
88        }
89
90        return ScanResult(
91            findings=findings,
92            coverage_score=coverage_score,
93            scan_metadata=scan_metadata,
94            policy_hash=self.policy.policy_hash or "",
95            timestamp=datetime.now(),
96            dataset_name=dataset_name,
97            total_rows=len(df),
98            total_columns=len(df.columns),
99        )

Scan a pandas DataFrame for PII.

Args: df: DataFrame to scan dataset_name: Name of the dataset for reporting confidence_threshold: Override policy confidence threshold

Returns: ScanResult with detected PII and metadata

def scan_text( self, text: str, confidence_threshold: Optional[float] = None) -> List[Finding]:
219    def scan_text(
220        self, text: str, confidence_threshold: Optional[float] = None
221    ) -> List[Finding]:
222        """
223        Scan a text string for PII.
224
225        Args:
226            text: Text to scan
227            confidence_threshold: Override policy confidence threshold
228
229        Returns:
230            List of findings
231        """
232        threshold = self._resolve_threshold(confidence_threshold)
233        return self._scan_value(text, threshold, "text", 0)

Scan a text string for PII.

Args: text: Text to scan confidence_threshold: Override policy confidence threshold

Returns: List of findings

def scan_text_result( self, text: str, dataset_name: str = 'text', confidence_threshold: Optional[float] = None) -> ScanResult:
235    def scan_text_result(
236        self,
237        text: str,
238        dataset_name: str = "text",
239        confidence_threshold: Optional[float] = None,
240    ) -> ScanResult:
241        """Scan text and return a ScanResult object for reporting."""
242        threshold = self._resolve_threshold(confidence_threshold)
243        findings = self.scan_text(text, threshold)
244
245        coverage_score = self._calculate_coverage_score(findings, dataset_name)
246
247        scan_metadata = {
248            "scan_duration": 0.0,
249            "confidence_threshold": threshold,
250            "detectors_used": list(self.detector_registry.get_detector_names()),
251            "locale_packs": self.policy.locale_packs,
252        }
253
254        return ScanResult(
255            findings=findings,
256            coverage_score=coverage_score,
257            scan_metadata=scan_metadata,
258            policy_hash=self.policy.policy_hash or "",
259            timestamp=datetime.now(),
260            dataset_name=dataset_name,
261            total_rows=1,
262            total_columns=1,
263        )

Scan text and return a ScanResult object for reporting.

def scan_file( self, file_path: Union[str, pathlib.Path], confidence_threshold: Optional[float] = None) -> ScanResult:
265    def scan_file(
266        self,
267        file_path: Union[str, Path],
268        confidence_threshold: Optional[float] = None,
269    ) -> ScanResult:
270        """
271        Stream-scan a text or CSV file without loading it fully into memory.
272
273        Supports:
274        - .txt/.md: line-by-line
275        - .csv: row-by-row via csv module
276
277        For JSON/Parquet and other tabular formats, use SDKScanner (pandas) or
278        CLI load helpers.
279        """
280        start_time = time.time()
281        path = Path(file_path)
282        if not path.exists():
283            raise FileNotFoundError(f"File not found: {path}")
284
285        threshold = self._resolve_threshold(confidence_threshold)
286        findings: List[Finding] = []
287        total_rows = 0
288        total_columns = 1
289        dataset_name = path.stem
290
291        suffix = path.suffix.lower()
292        if suffix in [".txt", ".md"]:
293            with open(path, "r", encoding="utf-8", errors="ignore") as f:
294                for line_idx, line in enumerate(f):
295                    line = line.rstrip("\n")
296                    findings.extend(self._scan_value(line, threshold, "line", line_idx))
297                    total_rows += 1
298            total_columns = 1
299        elif suffix == ".csv":
300            with open(path, "r", encoding="utf-8", newline="") as f:
301                reader = csv.reader(f)
302                headers = next(reader, [])
303                total_columns = len(headers)
304                for row_idx, row in enumerate(reader):
305                    # Track rows processed
306                    total_rows += 1
307                    for col_idx, cell in enumerate(row):
308                        column_name = (
309                            headers[col_idx]
310                            if col_idx < len(headers)
311                            else f"col_{col_idx}"
312                        )
313                        findings.extend(
314                            self._scan_value(
315                                cell,
316                                threshold,
317                                column_name,
318                                row_idx,
319                                {"column_name": column_name},
320                            )
321                        )
322        else:
323            raise ValueError(
324                f"Unsupported file format for streaming scan: {suffix}. "
325                "Use SDKScanner (pandas) for JSON/Parquet."
326            )
327
328        coverage_score = self._calculate_coverage_score(findings, dataset_name)
329
330        scan_metadata = {
331            "scan_duration": time.time() - start_time,
332            "confidence_threshold": threshold,
333            "detectors_used": list(self.detector_registry.get_detector_names()),
334            "locale_packs": self.policy.locale_packs,
335            "file_path": str(path),
336            "streaming": True,
337        }
338
339        return ScanResult(
340            findings=findings,
341            coverage_score=coverage_score,
342            scan_metadata=scan_metadata,
343            policy_hash=self.policy.policy_hash or "",
344            timestamp=datetime.now(),
345            dataset_name=dataset_name,
346            total_rows=total_rows,
347            total_columns=total_columns,
348        )

Stream-scan a text or CSV file without loading it fully into memory.

Supports:

  • .txt/.md: line-by-line
  • .csv: row-by-row via csv module

For JSON/Parquet and other tabular formats, use SDKScanner (pandas) or CLI load helpers.

def scan_dict( self, data: Dict[str, Any], confidence_threshold: Optional[float] = None) -> List[Finding]:
350    def scan_dict(
351        self, data: Dict[str, Any], confidence_threshold: Optional[float] = None
352    ) -> List[Finding]:
353        """
354        Scan a dictionary for PII.
355
356        Args:
357            data: Dictionary to scan
358            confidence_threshold: Override policy confidence threshold
359
360        Returns:
361            List of findings
362        """
363        threshold = self._resolve_threshold(confidence_threshold)
364        findings: List[Finding] = []
365
366        for key, value in data.items():
367            if isinstance(value, str):
368                text_findings = self.scan_text(value, threshold)
369                # Update column name for each finding
370                for finding in text_findings:
371                    finding.column = key
372                    findings.append(finding)
373
374        return findings

Scan a dictionary for PII.

Args: data: Dictionary to scan confidence_threshold: Override policy confidence threshold

Returns: List of findings

class Transform:
 23class Transform:
 24    """
 25    Core PII transform that applies transformations to sensitive data.
 26
 27    The transform uses the scanner to detect PII and then applies policy-defined
 28    transformations to protect the data while maintaining utility.
 29    """
 30
 31    def __init__(self, policy: Policy) -> None:
 32        """
 33        Initialize transform with a policy.
 34
 35        Args:
 36            policy: Policy configuration for transformation rules
 37        """
 38        self.policy = policy
 39        self.scanner = Scanner(policy)
 40        self.transform_registry = TransformRegistry()
 41
 42    def transform_dataframe(
 43        self,
 44        df: Any,
 45        dataset_name: str = "unknown",
 46        dry_run: bool = False,
 47        job_name: Optional[str] = None,
 48    ) -> tuple[Any, AuditReport]:
 49        """
 50        No PII in a pandas DataFrame.
 51
 52        Args:
 53            df: DataFrame to transform
 54            dataset_name: Name of the dataset for reporting
 55            dry_run: If True, only generate report without modifying data
 56            job_name: Name for the audit report
 57
 58        Returns:
 59            Tuple of (transform_dataframe, audit_report)
 60        """
 61        start_time = time.time()
 62
 63        # First scan for PII
 64        scan_result = self.scanner.scan_dataframe(df, dataset_name)
 65
 66        if dry_run:
 67            # For dry run, just return original data with report
 68            audit_report = self._create_audit_report(
 69                scan_result, job_name or f"dry_run_{dataset_name}", start_time
 70            )
 71            return df.copy(), audit_report
 72
 73        # Guard optional dependency
 74        if pd is None:
 75            raise ImportError(
 76                "pandas is required for transform_dataframe; install pandas to use this feature"
 77            )
 78
 79        # Create a copy to modify
 80        df_transform = df.copy()
 81
 82        # Apply transformations (grouped per cell to avoid span drift)
 83        samples: Dict[str, List[str]] = {}
 84
 85        # Ensure columns with findings can safely accept string replacements.
 86        # This avoids FutureWarning/TypeError when assigning strings into
 87        # non-object dtypes (e.g., datetime64[ns], numeric).
 88        if pd is not None and hasattr(df_transform, "dtypes"):
 89            try:
 90                # Only cast columns that will be modified (i.e., not allowed by policy)
 91                finding_columns = set()
 92                for f in scan_result.findings:
 93                    if (
 94                        hasattr(df_transform, "columns")
 95                        and f.column in df_transform.columns
 96                        and not self.policy.is_allowed(dataset_name, f.type)
 97                    ):
 98                        finding_columns.add(f.column)
 99                for col in finding_columns:
100                    dtype = df_transform[col].dtype
101                    # Cast only if not already an object/string dtype
102                    if not (
103                        pd.api.types.is_object_dtype(dtype)
104                        or pd.api.types.is_string_dtype(dtype)
105                    ):
106                        # Prefer pandas' nullable string dtype when available
107                        try:
108                            df_transform[col] = df_transform[col].astype("string")
109                        except Exception:
110                            # Fallback to plain object strings
111                            df_transform[col] = (
112                                df_transform[col].astype(object).astype(str)
113                            )
114            except Exception as e:
115                # Proceed without pre-casting; log for diagnostics.
116                logging.debug(
117                    "nopii.transform: skipping pre-cast for columns due to error: %s",
118                    e,
119                )
120
121        cell_map: Dict[tuple[int, str], List[Finding]] = {}
122        for f in scan_result.findings:
123            cell_map.setdefault((f.row_index, f.column), []).append(f)
124
125        for (row_idx, column), cell_findings in cell_map.items():
126            cell_text = str(
127                df_transform.iloc[row_idx, df_transform.columns.get_loc(column)]
128            )
129            # Sort by start position descending
130            for finding in sorted(cell_findings, key=lambda x: x.span[0], reverse=True):
131                if self.policy.is_allowed(dataset_name, finding.type):
132                    finding.action_taken = "allow"
133                    continue
134
135                action = self._get_action_for_finding(finding)
136                transformed_value = self._apply_transformation(
137                    finding.value, action, finding.type
138                )
139
140                start, end = finding.span
141                if 0 <= start <= end <= len(cell_text):
142                    cell_text = cell_text[:start] + transformed_value + cell_text[end:]
143                else:
144                    cell_text = cell_text.replace(finding.value, transformed_value, 1)
145
146                finding.transformed_value = transformed_value
147                finding.action_taken = action
148
149                if action not in samples:
150                    samples[action] = []
151                if len(samples[action]) < self.policy.reporting["store_samples"]:
152                    samples[action].append(f"{finding.value} → {transformed_value}")
153
154            df_transform.iloc[row_idx, df_transform.columns.get_loc(column)] = cell_text
155
156        # Create audit report
157        audit_report = self._create_audit_report(
158            scan_result, job_name or f"transform_{dataset_name}", start_time, samples
159        )
160
161        return df_transform, audit_report
162
163    def _get_action_for_finding(self, finding: Finding) -> str:
164        """Determine the transformation action for a finding."""
165        # Check column-specific rule first
166        column_rule = self.policy.get_rule_for_column(finding.column)
167        if column_rule:
168            return column_rule.action
169
170        # Check type-specific rule
171        type_rule = self.policy.get_rule_for_type(finding.type)
172        if type_rule:
173            return type_rule.action
174
175        # Use default action
176        return self.policy.default_action
177
178    def _apply_transformation(self, value: str, action: str, pii_type: str) -> str:
179        """Apply transformation using registry and return the transformed string."""
180        options = self._get_transformation_options(action, pii_type)
181        result = self.transform_registry.transform(value, pii_type, action, options)
182        if not result.success or result.transformed_value is None:
183            fallback = self.transform_registry.transform(
184                value, pii_type, "transform", options
185            )
186            return (
187                fallback.transformed_value
188                if fallback.transformed_value is not None
189                else value
190            )
191        return result.transformed_value
192
193    def _get_transformation_options(self, action: str, pii_type: str) -> Dict[str, Any]:
194        """Get transformation options from policy rules."""
195        # Check type-specific rule for options
196        type_rule = self.policy.get_rule_for_type(pii_type)
197        if type_rule and type_rule.options:
198            return type_rule.options
199
200        # Return default options
201        return {}
202
203    # Note: Replacement is handled inline during transform passes to avoid span drift.
204
205    def _create_audit_report(
206        self,
207        scan_result: ScanResult,
208        job_name: str,
209        start_time: float,
210        samples: Optional[Dict[str, List[str]]] = None,
211    ) -> AuditReport:
212        """Create a comprehensive audit report."""
213        end_time = time.time()
214
215        # Calculate residual risk
216        residual_risk = self._calculate_residual_risk(scan_result)
217
218        # Performance metrics
219        performance_metrics = {
220            "total_duration": end_time - start_time,
221            "total_time": end_time - start_time,
222            "scan_duration": scan_result.scan_metadata.get("scan_duration", 0.0),
223            "transform_duration": (end_time - start_time)
224            - scan_result.scan_metadata.get("scan_duration", 0.0),
225            "rows_per_second": scan_result.total_rows / (end_time - start_time)
226            if (end_time - start_time) > 0
227            else 0,
228        }
229
230        return AuditReport(
231            job_name=job_name,
232            timestamp=datetime.now(),
233            policy_hash=self.policy.policy_hash or "",
234            coverage_score=scan_result.coverage_score,
235            residual_risk=residual_risk,
236            summary_stats=scan_result.get_summary_stats(),
237            findings_by_type=scan_result.get_findings_by_type(),
238            performance_metrics=performance_metrics,
239            samples=samples or {},
240            scan_result=scan_result,
241        )
242
243    def _calculate_residual_risk(self, scan_result: ScanResult) -> float:
244        """
245        Calculate residual risk score based on unprotected PII and confidence levels.
246
247        Residual Risk = weighted average of:
248        - Unprotected PII ratio
249        - Low confidence detection ratio
250        - Coverage gap
251        """
252        total_findings = len(scan_result.findings)
253        if total_findings == 0:
254            return 0.0
255
256        # Count unprotected findings
257        unprotected = len([f for f in scan_result.findings if not f.action_taken])
258        unprotected_ratio = unprotected / total_findings
259
260        # Count low confidence findings
261        low_confidence = len([f for f in scan_result.findings if f.confidence < 0.5])
262        low_confidence_ratio = low_confidence / total_findings
263
264        # Coverage gap
265        coverage_gap = 1.0 - scan_result.coverage_score
266
267        # Weighted average (can be tuned based on organizational risk tolerance)
268        residual_risk = (
269            0.5 * unprotected_ratio + 0.3 * coverage_gap + 0.2 * low_confidence_ratio
270        )
271
272        return min(residual_risk, 1.0)
273
274    def transform_text(
275        self, text: str, dry_run: bool = False
276    ) -> tuple[str, List[Finding]]:
277        """
278        No PII in a text string.
279
280        Args:
281            text: Text to transform
282            dry_run: If True, only detect without modifying
283
284        Returns:
285            Tuple of (transform_text, findings)
286        """
287        findings = self.scanner.scan_text(text)
288
289        if dry_run:
290            return text, findings
291
292        transform_text = text
293
294        # Sort findings by span in reverse order to avoid offset issues
295        sorted_findings = sorted(findings, key=lambda f: f.span[0], reverse=True)
296
297        for finding in sorted_findings:
298            action = self._get_action_for_finding(finding)
299            transformed_value = self._apply_transformation(
300                finding.value, action, finding.type
301            )
302
303            # Replace in text
304            start, end = finding.span
305            transform_text = (
306                transform_text[:start] + transformed_value + transform_text[end:]
307            )
308
309            # Update finding
310            finding.transformed_value = transformed_value
311            finding.action_taken = action
312
313        return transform_text, findings
314
315    def transform_dict(
316        self, data: Dict[str, Any], dry_run: bool = False
317    ) -> tuple[Dict[str, Any], List[Finding]]:
318        """
319        No PII in a dictionary.
320
321        Args:
322            data: Dictionary to transform
323            dry_run: If True, only detect without modifying
324
325        Returns:
326            Tuple of (transform_dict, findings)
327        """
328        findings = self.scanner.scan_dict(data)
329
330        if dry_run:
331            return data.copy(), findings
332
333        transform_data = data.copy()
334
335        for finding in findings:
336            if finding.column in transform_data:
337                action = self._get_action_for_finding(finding)
338                transformed_value = self._apply_transformation(
339                    finding.value, action, finding.type
340                )
341
342                # Replace in dictionary value
343                original_value = str(transform_data[finding.column])
344                transform_data[finding.column] = original_value.replace(
345                    finding.value, transformed_value, 1
346                )
347
348                # Update finding
349                finding.transformed_value = transformed_value
350                finding.action_taken = action
351
352        return transform_data, findings
353
354    def transform_text_with_report(
355        self,
356        text: str,
357        dataset_name: str = "text",
358        job_name: Optional[str] = None,
359        dry_run: bool = False,
360    ) -> tuple[str, AuditReport]:
361        """Redact text and produce an AuditReport."""
362        start_time = time.time()
363        scan_result = self.scanner.scan_text_result(text, dataset_name)
364
365        if dry_run:
366            audit = self._create_audit_report(
367                scan_result, job_name or f"dry_run_{dataset_name}", start_time
368            )
369            return text, audit
370
371        transform_text = text
372        samples: Dict[str, List[str]] = {}
373        # Sort in reverse order to keep spans valid
374        for f in sorted(scan_result.findings, key=lambda f: f.span[0], reverse=True):
375            if self.policy.is_allowed(dataset_name, f.type):
376                f.action_taken = "allow"
377                continue
378            action = self._get_action_for_finding(f)
379            transformed_value = self._apply_transformation(f.value, action, f.type)
380            s, e = f.span
381            transform_text = transform_text[:s] + transformed_value + transform_text[e:]
382            f.transformed_value = transformed_value
383            f.action_taken = action
384            if action not in samples:
385                samples[action] = []
386            if len(samples[action]) < self.policy.reporting["store_samples"]:
387                samples[action].append(f"{f.value} → {transformed_value}")
388
389        audit = self._create_audit_report(
390            scan_result, job_name or f"transform_{dataset_name}", start_time, samples
391        )
392        return transform_text, audit

Core PII transform that applies transformations to sensitive data.

The transform uses the scanner to detect PII and then applies policy-defined transformations to protect the data while maintaining utility.

Transform(policy: Policy)
31    def __init__(self, policy: Policy) -> None:
32        """
33        Initialize transform with a policy.
34
35        Args:
36            policy: Policy configuration for transformation rules
37        """
38        self.policy = policy
39        self.scanner = Scanner(policy)
40        self.transform_registry = TransformRegistry()

Initialize transform with a policy.

Args: policy: Policy configuration for transformation rules

policy
scanner
transform_registry
def transform_dataframe( self, df: Any, dataset_name: str = 'unknown', dry_run: bool = False, job_name: Optional[str] = None) -> tuple[typing.Any, AuditReport]:
 42    def transform_dataframe(
 43        self,
 44        df: Any,
 45        dataset_name: str = "unknown",
 46        dry_run: bool = False,
 47        job_name: Optional[str] = None,
 48    ) -> tuple[Any, AuditReport]:
 49        """
 50        No PII in a pandas DataFrame.
 51
 52        Args:
 53            df: DataFrame to transform
 54            dataset_name: Name of the dataset for reporting
 55            dry_run: If True, only generate report without modifying data
 56            job_name: Name for the audit report
 57
 58        Returns:
 59            Tuple of (transform_dataframe, audit_report)
 60        """
 61        start_time = time.time()
 62
 63        # First scan for PII
 64        scan_result = self.scanner.scan_dataframe(df, dataset_name)
 65
 66        if dry_run:
 67            # For dry run, just return original data with report
 68            audit_report = self._create_audit_report(
 69                scan_result, job_name or f"dry_run_{dataset_name}", start_time
 70            )
 71            return df.copy(), audit_report
 72
 73        # Guard optional dependency
 74        if pd is None:
 75            raise ImportError(
 76                "pandas is required for transform_dataframe; install pandas to use this feature"
 77            )
 78
 79        # Create a copy to modify
 80        df_transform = df.copy()
 81
 82        # Apply transformations (grouped per cell to avoid span drift)
 83        samples: Dict[str, List[str]] = {}
 84
 85        # Ensure columns with findings can safely accept string replacements.
 86        # This avoids FutureWarning/TypeError when assigning strings into
 87        # non-object dtypes (e.g., datetime64[ns], numeric).
 88        if pd is not None and hasattr(df_transform, "dtypes"):
 89            try:
 90                # Only cast columns that will be modified (i.e., not allowed by policy)
 91                finding_columns = set()
 92                for f in scan_result.findings:
 93                    if (
 94                        hasattr(df_transform, "columns")
 95                        and f.column in df_transform.columns
 96                        and not self.policy.is_allowed(dataset_name, f.type)
 97                    ):
 98                        finding_columns.add(f.column)
 99                for col in finding_columns:
100                    dtype = df_transform[col].dtype
101                    # Cast only if not already an object/string dtype
102                    if not (
103                        pd.api.types.is_object_dtype(dtype)
104                        or pd.api.types.is_string_dtype(dtype)
105                    ):
106                        # Prefer pandas' nullable string dtype when available
107                        try:
108                            df_transform[col] = df_transform[col].astype("string")
109                        except Exception:
110                            # Fallback to plain object strings
111                            df_transform[col] = (
112                                df_transform[col].astype(object).astype(str)
113                            )
114            except Exception as e:
115                # Proceed without pre-casting; log for diagnostics.
116                logging.debug(
117                    "nopii.transform: skipping pre-cast for columns due to error: %s",
118                    e,
119                )
120
121        cell_map: Dict[tuple[int, str], List[Finding]] = {}
122        for f in scan_result.findings:
123            cell_map.setdefault((f.row_index, f.column), []).append(f)
124
125        for (row_idx, column), cell_findings in cell_map.items():
126            cell_text = str(
127                df_transform.iloc[row_idx, df_transform.columns.get_loc(column)]
128            )
129            # Sort by start position descending
130            for finding in sorted(cell_findings, key=lambda x: x.span[0], reverse=True):
131                if self.policy.is_allowed(dataset_name, finding.type):
132                    finding.action_taken = "allow"
133                    continue
134
135                action = self._get_action_for_finding(finding)
136                transformed_value = self._apply_transformation(
137                    finding.value, action, finding.type
138                )
139
140                start, end = finding.span
141                if 0 <= start <= end <= len(cell_text):
142                    cell_text = cell_text[:start] + transformed_value + cell_text[end:]
143                else:
144                    cell_text = cell_text.replace(finding.value, transformed_value, 1)
145
146                finding.transformed_value = transformed_value
147                finding.action_taken = action
148
149                if action not in samples:
150                    samples[action] = []
151                if len(samples[action]) < self.policy.reporting["store_samples"]:
152                    samples[action].append(f"{finding.value} → {transformed_value}")
153
154            df_transform.iloc[row_idx, df_transform.columns.get_loc(column)] = cell_text
155
156        # Create audit report
157        audit_report = self._create_audit_report(
158            scan_result, job_name or f"transform_{dataset_name}", start_time, samples
159        )
160
161        return df_transform, audit_report

No PII in a pandas DataFrame.

Args: df: DataFrame to transform dataset_name: Name of the dataset for reporting dry_run: If True, only generate report without modifying data job_name: Name for the audit report

Returns: Tuple of (transform_dataframe, audit_report)

def transform_text( self, text: str, dry_run: bool = False) -> tuple[str, typing.List[Finding]]:
274    def transform_text(
275        self, text: str, dry_run: bool = False
276    ) -> tuple[str, List[Finding]]:
277        """
278        No PII in a text string.
279
280        Args:
281            text: Text to transform
282            dry_run: If True, only detect without modifying
283
284        Returns:
285            Tuple of (transform_text, findings)
286        """
287        findings = self.scanner.scan_text(text)
288
289        if dry_run:
290            return text, findings
291
292        transform_text = text
293
294        # Sort findings by span in reverse order to avoid offset issues
295        sorted_findings = sorted(findings, key=lambda f: f.span[0], reverse=True)
296
297        for finding in sorted_findings:
298            action = self._get_action_for_finding(finding)
299            transformed_value = self._apply_transformation(
300                finding.value, action, finding.type
301            )
302
303            # Replace in text
304            start, end = finding.span
305            transform_text = (
306                transform_text[:start] + transformed_value + transform_text[end:]
307            )
308
309            # Update finding
310            finding.transformed_value = transformed_value
311            finding.action_taken = action
312
313        return transform_text, findings

No PII in a text string.

Args: text: Text to transform dry_run: If True, only detect without modifying

Returns: Tuple of (transform_text, findings)

def transform_dict( self, data: Dict[str, Any], dry_run: bool = False) -> tuple[typing.Dict[str, typing.Any], typing.List[Finding]]:
315    def transform_dict(
316        self, data: Dict[str, Any], dry_run: bool = False
317    ) -> tuple[Dict[str, Any], List[Finding]]:
318        """
319        No PII in a dictionary.
320
321        Args:
322            data: Dictionary to transform
323            dry_run: If True, only detect without modifying
324
325        Returns:
326            Tuple of (transform_dict, findings)
327        """
328        findings = self.scanner.scan_dict(data)
329
330        if dry_run:
331            return data.copy(), findings
332
333        transform_data = data.copy()
334
335        for finding in findings:
336            if finding.column in transform_data:
337                action = self._get_action_for_finding(finding)
338                transformed_value = self._apply_transformation(
339                    finding.value, action, finding.type
340                )
341
342                # Replace in dictionary value
343                original_value = str(transform_data[finding.column])
344                transform_data[finding.column] = original_value.replace(
345                    finding.value, transformed_value, 1
346                )
347
348                # Update finding
349                finding.transformed_value = transformed_value
350                finding.action_taken = action
351
352        return transform_data, findings

No PII in a dictionary.

Args: data: Dictionary to transform dry_run: If True, only detect without modifying

Returns: Tuple of (transform_dict, findings)

def transform_text_with_report( self, text: str, dataset_name: str = 'text', job_name: Optional[str] = None, dry_run: bool = False) -> tuple[str, AuditReport]:
354    def transform_text_with_report(
355        self,
356        text: str,
357        dataset_name: str = "text",
358        job_name: Optional[str] = None,
359        dry_run: bool = False,
360    ) -> tuple[str, AuditReport]:
361        """Redact text and produce an AuditReport."""
362        start_time = time.time()
363        scan_result = self.scanner.scan_text_result(text, dataset_name)
364
365        if dry_run:
366            audit = self._create_audit_report(
367                scan_result, job_name or f"dry_run_{dataset_name}", start_time
368            )
369            return text, audit
370
371        transform_text = text
372        samples: Dict[str, List[str]] = {}
373        # Sort in reverse order to keep spans valid
374        for f in sorted(scan_result.findings, key=lambda f: f.span[0], reverse=True):
375            if self.policy.is_allowed(dataset_name, f.type):
376                f.action_taken = "allow"
377                continue
378            action = self._get_action_for_finding(f)
379            transformed_value = self._apply_transformation(f.value, action, f.type)
380            s, e = f.span
381            transform_text = transform_text[:s] + transformed_value + transform_text[e:]
382            f.transformed_value = transformed_value
383            f.action_taken = action
384            if action not in samples:
385                samples[action] = []
386            if len(samples[action]) < self.policy.reporting["store_samples"]:
387                samples[action].append(f"{f.value} → {transformed_value}")
388
389        audit = self._create_audit_report(
390            scan_result, job_name or f"transform_{dataset_name}", start_time, samples
391        )
392        return transform_text, audit

Redact text and produce an AuditReport.

def load_policy( source: Union[str, pathlib.Path, Dict[str, Any]]) -> Policy:
15def load_policy(source: Union[str, Path, Dict[str, Any]]) -> Policy:
16    """
17    Load a policy from various sources.
18
19    Args:
20        source: Can be a file path, YAML string, or dictionary
21
22    Returns:
23        Policy instance
24
25    Raises:
26        ValueError: If policy is invalid
27        FileNotFoundError: If file doesn't exist
28    """
29    if isinstance(source, dict):
30        return load_policy_from_dict(source)
31    elif isinstance(source, Path):
32        if source.exists():
33            return load_policy_from_file(source)
34        raise FileNotFoundError(f"Policy file not found: {source}")
35    elif isinstance(source, str):
36        path = Path(source)
37        if path.exists():
38            return load_policy_from_file(path)
39        # Try to parse as YAML string
40        try:
41            data = yaml.safe_load(source)
42            return load_policy_from_dict(data)
43        except yaml.YAMLError as e:
44            raise ValueError(f"Invalid YAML string: {e}")
45    else:
46        raise ValueError(f"Unsupported source type: {type(source)}")

Load a policy from various sources.

Args: source: Can be a file path, YAML string, or dictionary

Returns: Policy instance

Raises: ValueError: If policy is invalid FileNotFoundError: If file doesn't exist

def create_default_policy() -> Policy:
129def create_default_policy() -> Policy:
130    """
131    Create a default policy configuration.
132
133    Returns:
134        Default Policy instance
135    """
136    default_config = {
137        "name": "default_policy",
138        "version": "1",
139        "locale_packs": ["generic"],
140        "default_action": "mask",
141        "thresholds": {
142            "min_confidence": 0.65,
143            "fail_on_untransform": False,
144            "coverage_target": 0.85,
145        },
146        "reporting": {
147            "formats": ["json"],
148            "output_dir": "reports",
149            "store_samples": 3,
150            "include_trends": False,
151        },
152        "secrets": {
153            "tokenization_key_env": "REDACT_PII_KEY",
154            "namespace_env": "REDACT_PII_NS",
155        },
156        "rules": [
157            {
158                "action": "mask",
159                "match": "email",
160                "options": {"preserve_format": True, "mask_char": "*"},
161            },
162            {
163                "action": "mask",
164                "match": "phone",
165                "options": {
166                    "preserve_format": True,
167                    "preserve_last": 4,
168                    "mask_char": "*",
169                },
170            },
171            {
172                "action": "hash",
173                "match": "ssn",
174                "options": {"algorithm": "sha256", "include_prefix": True},
175            },
176        ],
177        "exceptions": [],
178    }
179
180    return load_policy_from_dict(default_config)

Create a default policy configuration.

Returns: Default Policy instance

def save_policy( policy: Policy, file_path: Union[str, pathlib.Path]) -> None:
183def save_policy(policy: Policy, file_path: Union[str, Path]) -> None:
184    """
185    Save a policy to a YAML file.
186
187    Args:
188        policy: Policy instance to save
189        file_path: Path where to save the policy
190    """
191    path = Path(file_path)
192
193    # Convert policy to dictionary
194    policy_dict = policy.model_dump(exclude={"policy_hash"})
195
196    # Ensure parent directory exists
197    path.parent.mkdir(parents=True, exist_ok=True)
198
199    # Write YAML file
200    with open(path, "w", encoding="utf-8") as f:
201        yaml.dump(
202            policy_dict,
203            f,
204            default_flow_style=False,
205            sort_keys=False,
206            indent=2,
207            allow_unicode=True,
208        )

Save a policy to a YAML file.

Args: policy: Policy instance to save file_path: Path where to save the policy

class DetectorRegistry:
 11class DetectorRegistry:
 12    """
 13    Registry for managing and accessing PII detectors.
 14
 15    The registry allows registration of custom detectors and provides
 16    methods to retrieve detectors by name or type.
 17    """
 18
 19    def __init__(self):
 20        """Initialize the detector registry."""
 21        self._detectors: Dict[str, BaseDetector] = {}
 22        self._load_default_detectors()
 23
 24    def register(self, detector: BaseDetector) -> None:
 25        """
 26        Register a detector.
 27
 28        Args:
 29            detector: Detector instance to register
 30        """
 31        self._detectors[detector.name] = detector
 32
 33    def unregister(self, name: str) -> bool:
 34        """
 35        Unregister a detector by name.
 36
 37        Args:
 38            name: Name of detector to remove
 39
 40        Returns:
 41            True if detector was found and removed
 42        """
 43        if name in self._detectors:
 44            del self._detectors[name]
 45            return True
 46        return False
 47
 48    def get_detector(self, name: str) -> Optional[BaseDetector]:
 49        """
 50        Get a detector by name.
 51
 52        Args:
 53            name: Name of the detector
 54
 55        Returns:
 56            Detector instance or None if not found
 57        """
 58        return self._detectors.get(name)
 59
 60    def list_detectors(self) -> List[str]:
 61        """
 62        List all registered detector names.
 63
 64        Returns:
 65            List of detector names
 66        """
 67        return list(self._detectors.keys())
 68
 69    def get_detectors_by_type(self, pii_type: str) -> List[BaseDetector]:
 70        """
 71        Get all detectors that can detect a specific PII type.
 72
 73        Args:
 74            pii_type: Type of PII to find detectors for
 75
 76        Returns:
 77            List of matching detectors
 78        """
 79        return [
 80            detector
 81            for detector in self._detectors.values()
 82            if detector.pii_type == pii_type
 83        ]
 84
 85    def get_all_detectors(self) -> List[BaseDetector]:
 86        """
 87        Get all registered detectors.
 88
 89        Returns:
 90            List of all detector instances
 91        """
 92        return list(self._detectors.values())
 93
 94    def get_detector_info(self) -> List[Dict[str, Any]]:
 95        """
 96        Get information about all registered detectors.
 97
 98        Returns:
 99            List of detector information dictionaries
100        """
101        return [detector.get_info() for detector in self._detectors.values()]
102
103    def configure_detector(self, name: str, config: Dict[str, Any]) -> bool:
104        """
105        Configure a specific detector.
106
107        Args:
108            name: Name of the detector to configure
109            config: Configuration dictionary
110
111        Returns:
112            True if detector was found and configured successfully
113        """
114        detector = self.get_detector(name)
115        if detector and detector.validate_config(config):
116            detector.configure(config)
117            return True
118        return False
119
120    def load_locale_pack(self, locale_pack: str) -> None:
121        """
122        Load detectors for a specific locale pack.
123
124        Args:
125            locale_pack: Name of the locale pack to load
126        """
127        # For now, just load default detectors regardless of locale
128        # TODO: Implement locale-specific detector loading
129        pass
130
131    def get_detectors(
132        self, pii_types: Optional[List[str]] = None
133    ) -> List[BaseDetector]:
134        """
135        Get detectors for specific PII types or all detectors.
136
137        Args:
138            pii_types: List of PII types to get detectors for, or None for all
139
140        Returns:
141            List of detector instances
142        """
143        if pii_types is None:
144            return self.get_all_detectors()
145
146        detectors = []
147        for pii_type in pii_types:
148            detectors.extend(self.get_detectors_by_type(pii_type))
149
150        # Remove duplicates while preserving order
151        seen = set()
152        unique_detectors = []
153        for detector in detectors:
154            if detector.name not in seen:
155                seen.add(detector.name)
156                unique_detectors.append(detector)
157
158        return unique_detectors
159
160    def get_detector_names(self) -> List[str]:
161        """
162        Get names of all registered detectors.
163
164        Returns:
165            List of detector names
166        """
167        return self.list_detectors()
168
169    def _load_default_detectors(self) -> None:
170        """Load the default set of PII detectors."""
171        # Import and register default detectors
172        from .patterns import (
173            CreditCardDetector,
174            EmailDetector,
175            PhoneDetector,
176            SSNDetector,
177            IPAddressDetector,
178            URLDetector,
179            PersonNameDetector,
180            AddressDetector,
181            DateOfBirthDetector,
182            DriversLicenseDetector,
183        )
184
185        # Register all default detectors
186        default_detectors = [
187            EmailDetector(),
188            PhoneDetector(),
189            CreditCardDetector(),
190            SSNDetector(),
191            IPAddressDetector(),
192            URLDetector(),
193            PersonNameDetector(),
194            AddressDetector(),
195            DateOfBirthDetector(),
196            DriversLicenseDetector(),
197        ]
198
199        for detector in default_detectors:
200            self.register(detector)

Registry for managing and accessing PII detectors.

The registry allows registration of custom detectors and provides methods to retrieve detectors by name or type.

DetectorRegistry()
19    def __init__(self):
20        """Initialize the detector registry."""
21        self._detectors: Dict[str, BaseDetector] = {}
22        self._load_default_detectors()

Initialize the detector registry.

def register(self, detector: nopii.detectors.base.BaseDetector) -> None:
24    def register(self, detector: BaseDetector) -> None:
25        """
26        Register a detector.
27
28        Args:
29            detector: Detector instance to register
30        """
31        self._detectors[detector.name] = detector

Register a detector.

Args: detector: Detector instance to register

def unregister(self, name: str) -> bool:
33    def unregister(self, name: str) -> bool:
34        """
35        Unregister a detector by name.
36
37        Args:
38            name: Name of detector to remove
39
40        Returns:
41            True if detector was found and removed
42        """
43        if name in self._detectors:
44            del self._detectors[name]
45            return True
46        return False

Unregister a detector by name.

Args: name: Name of detector to remove

Returns: True if detector was found and removed

def get_detector(self, name: str) -> Optional[nopii.detectors.base.BaseDetector]:
48    def get_detector(self, name: str) -> Optional[BaseDetector]:
49        """
50        Get a detector by name.
51
52        Args:
53            name: Name of the detector
54
55        Returns:
56            Detector instance or None if not found
57        """
58        return self._detectors.get(name)

Get a detector by name.

Args: name: Name of the detector

Returns: Detector instance or None if not found

def list_detectors(self) -> List[str]:
60    def list_detectors(self) -> List[str]:
61        """
62        List all registered detector names.
63
64        Returns:
65            List of detector names
66        """
67        return list(self._detectors.keys())

List all registered detector names.

Returns: List of detector names

def get_detectors_by_type(self, pii_type: str) -> List[nopii.detectors.base.BaseDetector]:
69    def get_detectors_by_type(self, pii_type: str) -> List[BaseDetector]:
70        """
71        Get all detectors that can detect a specific PII type.
72
73        Args:
74            pii_type: Type of PII to find detectors for
75
76        Returns:
77            List of matching detectors
78        """
79        return [
80            detector
81            for detector in self._detectors.values()
82            if detector.pii_type == pii_type
83        ]

Get all detectors that can detect a specific PII type.

Args: pii_type: Type of PII to find detectors for

Returns: List of matching detectors

def get_all_detectors(self) -> List[nopii.detectors.base.BaseDetector]:
85    def get_all_detectors(self) -> List[BaseDetector]:
86        """
87        Get all registered detectors.
88
89        Returns:
90            List of all detector instances
91        """
92        return list(self._detectors.values())

Get all registered detectors.

Returns: List of all detector instances

def get_detector_info(self) -> List[Dict[str, Any]]:
 94    def get_detector_info(self) -> List[Dict[str, Any]]:
 95        """
 96        Get information about all registered detectors.
 97
 98        Returns:
 99            List of detector information dictionaries
100        """
101        return [detector.get_info() for detector in self._detectors.values()]

Get information about all registered detectors.

Returns: List of detector information dictionaries

def configure_detector(self, name: str, config: Dict[str, Any]) -> bool:
103    def configure_detector(self, name: str, config: Dict[str, Any]) -> bool:
104        """
105        Configure a specific detector.
106
107        Args:
108            name: Name of the detector to configure
109            config: Configuration dictionary
110
111        Returns:
112            True if detector was found and configured successfully
113        """
114        detector = self.get_detector(name)
115        if detector and detector.validate_config(config):
116            detector.configure(config)
117            return True
118        return False

Configure a specific detector.

Args: name: Name of the detector to configure config: Configuration dictionary

Returns: True if detector was found and configured successfully

def load_locale_pack(self, locale_pack: str) -> None:
120    def load_locale_pack(self, locale_pack: str) -> None:
121        """
122        Load detectors for a specific locale pack.
123
124        Args:
125            locale_pack: Name of the locale pack to load
126        """
127        # For now, just load default detectors regardless of locale
128        # TODO: Implement locale-specific detector loading
129        pass

Load detectors for a specific locale pack.

Args: locale_pack: Name of the locale pack to load

def get_detectors( self, pii_types: Optional[List[str]] = None) -> List[nopii.detectors.base.BaseDetector]:
131    def get_detectors(
132        self, pii_types: Optional[List[str]] = None
133    ) -> List[BaseDetector]:
134        """
135        Get detectors for specific PII types or all detectors.
136
137        Args:
138            pii_types: List of PII types to get detectors for, or None for all
139
140        Returns:
141            List of detector instances
142        """
143        if pii_types is None:
144            return self.get_all_detectors()
145
146        detectors = []
147        for pii_type in pii_types:
148            detectors.extend(self.get_detectors_by_type(pii_type))
149
150        # Remove duplicates while preserving order
151        seen = set()
152        unique_detectors = []
153        for detector in detectors:
154            if detector.name not in seen:
155                seen.add(detector.name)
156                unique_detectors.append(detector)
157
158        return unique_detectors

Get detectors for specific PII types or all detectors.

Args: pii_types: List of PII types to get detectors for, or None for all

Returns: List of detector instances

def get_detector_names(self) -> List[str]:
160    def get_detector_names(self) -> List[str]:
161        """
162        Get names of all registered detectors.
163
164        Returns:
165            List of detector names
166        """
167        return self.list_detectors()

Get names of all registered detectors.

Returns: List of detector names

class TransformRegistry:
 18class TransformRegistry:
 19    """
 20    Registry for managing and accessing PII transformers.
 21
 22    The registry allows registration of custom transformers and provides
 23    methods to retrieve transformers by name.
 24    """
 25
 26    def __init__(self):
 27        """Initialize the transformer registry."""
 28        # Store any object that provides a .transform(...) method
 29        self._transformers: Dict[str, _TransformLike] = {}
 30        self._load_default_transformers()
 31
 32    def register(
 33        self,
 34        name_or_transformer: Union[str, _TransformLike],
 35        transformer: Optional[_TransformLike] = None,
 36    ) -> None:
 37        """
 38        Register a transformer.
 39
 40        Args:
 41            name_or_transformer: Either transformer instance or name string
 42            transformer: Transformer instance (when first arg is name)
 43        """
 44        if transformer is None:
 45            # Called with just transformer instance
 46            if not isinstance(name_or_transformer, _TransformLike):
 47                raise ValueError("Expected object with transform method")
 48            transformer_instance = name_or_transformer
 49            name = transformer_instance.__class__.__name__.lower().replace(
 50                "transformer", ""
 51            )
 52            self._transformers[name] = transformer_instance
 53        else:
 54            # Called with name and transformer
 55            if not isinstance(name_or_transformer, str):
 56                raise ValueError("Expected string name when transformer is provided")
 57            if not isinstance(transformer, _TransformLike):
 58                raise ValueError("Expected object with transform method")
 59            self._transformers[name_or_transformer] = transformer
 60
 61    def unregister(self, name: str) -> bool:
 62        """
 63        Unregister a transformer by name.
 64
 65        Args:
 66            name: Name of transformer to remove
 67
 68        Returns:
 69            True if transformer was found and removed
 70        """
 71        if name in self._transformers:
 72            del self._transformers[name]
 73            return True
 74        return False
 75
 76    def get_transformer(self, name: str) -> Optional[_TransformLike]:
 77        """
 78        Get a transformer by name.
 79
 80        Args:
 81            name: Name of the transformer
 82
 83        Returns:
 84            Transformer instance or None if not found
 85        """
 86        return self._transformers.get(name)
 87
 88    def list_transformers(self) -> List[str]:
 89        """
 90        List all registered transformer names.
 91
 92        Returns:
 93            List of transformer names
 94        """
 95        return list(self._transformers.keys())
 96
 97    def get_all_transformers(self) -> List[_TransformLike]:
 98        """
 99        Get all registered transformers.
100
101        Returns:
102            List of all transformer instances
103        """
104        return list(self._transformers.values())
105
106    def get_transformer_info(
107        self, name: Optional[str] = None
108    ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]:
109        """
110        Get information about all registered transformers or a specific transformer.
111
112        Args:
113            name: Optional name of specific transformer to get info for
114
115        Returns:
116            Transformer info dict if name provided, list of dicts otherwise, or None if not found
117        """
118        if name:
119            transformer = self.get_transformer(name)
120            if transformer:
121                info = transformer.get_info()
122                # Add supported_types field if not present
123                if "supported_types" not in info:
124                    info["supported_types"] = [
125                        "email",
126                        "phone",
127                        "ssn",
128                        "credit_card",
129                        "name",
130                        "address",
131                    ]
132                return info
133            return None
134        return [transformer.get_info() for transformer in self._transformers.values()]
135
136    def transform(
137        self,
138        value: str,
139        pii_type: str,
140        transformation_type: str,
141        options: Optional[Dict[str, Any]] = None,
142    ) -> Any:
143        """
144        Transform a value using the specified transformation type.
145
146        Args:
147            value: The value to transform
148            pii_type: The type of PII (e.g., 'email', 'phone')
149            transformation_type: The type of transformation to apply
150            options: Optional transformation options
151
152        Returns:
153            TransformationResult object
154        """
155        transformer = self.get_transformer(transformation_type)
156        if not transformer:
157            from ..core.models import TransformationResult
158
159            return TransformationResult(
160                original_value=value,
161                transformed_value=value,
162                transformation_type=transformation_type,
163                pii_type=pii_type,
164                success=False,
165                error_message=f"Transformer '{transformation_type}' not found",
166            )
167
168        return transformer.transform(value, pii_type, options)
169
170    def batch_transform(
171        self,
172        values: List[str],
173        pii_types: List[str],
174        transformation_type: str,
175        options: Optional[Dict[str, Any]] = None,
176    ) -> List[Any]:
177        """
178        Transform multiple values using the specified transformation type.
179
180        Args:
181            values: List of values to transform
182            pii_types: List of PII types corresponding to each value
183            transformation_type: The type of transformation to apply
184            options: Optional transformation options
185
186        Returns:
187            List of TransformationResult objects
188        """
189        if len(values) != len(pii_types):
190            raise ValueError("Values and PII types lists must have the same length")
191
192        results = []
193        for value, pii_type in zip(values, pii_types):
194            result = self.transform(value, pii_type, transformation_type, options)
195            results.append(result)
196
197        return results
198
199    def get_supported_transformations(
200        self, pii_type: Optional[str] = None
201    ) -> List[str]:
202        """
203        Get list of supported transformation types.
204
205        Args:
206            pii_type: Optional PII type to filter transformations for
207
208        Returns:
209            List of transformation type names
210        """
211        # For now, return all registered transformers
212        # In the future, this could be filtered by PII type
213        return list(self._transformers.keys())
214
215    def _load_default_transformers(self) -> None:
216        """Load the default set of transformers."""
217        # Import and register default transformers
218        from .hash import HashTransformer
219        from .mask import MaskTransformer
220        from .nullify import NullifyTransformer
221        from .redact import RedactTransformer
222        from .tokenize import TokenizeTransformer
223
224        # Register all default transformers
225        default_transformers = [
226            RedactTransformer(),
227            MaskTransformer(),
228            HashTransformer(),
229            TokenizeTransformer(),
230            NullifyTransformer(),
231        ]
232
233        for transformer in default_transformers:
234            self.register(transformer)

Registry for managing and accessing PII transformers.

The registry allows registration of custom transformers and provides methods to retrieve transformers by name.

TransformRegistry()
26    def __init__(self):
27        """Initialize the transformer registry."""
28        # Store any object that provides a .transform(...) method
29        self._transformers: Dict[str, _TransformLike] = {}
30        self._load_default_transformers()

Initialize the transformer registry.

def register( self, name_or_transformer: Union[str, nopii.transforms.registry._TransformLike], transformer: Optional[nopii.transforms.registry._TransformLike] = None) -> None:
32    def register(
33        self,
34        name_or_transformer: Union[str, _TransformLike],
35        transformer: Optional[_TransformLike] = None,
36    ) -> None:
37        """
38        Register a transformer.
39
40        Args:
41            name_or_transformer: Either transformer instance or name string
42            transformer: Transformer instance (when first arg is name)
43        """
44        if transformer is None:
45            # Called with just transformer instance
46            if not isinstance(name_or_transformer, _TransformLike):
47                raise ValueError("Expected object with transform method")
48            transformer_instance = name_or_transformer
49            name = transformer_instance.__class__.__name__.lower().replace(
50                "transformer", ""
51            )
52            self._transformers[name] = transformer_instance
53        else:
54            # Called with name and transformer
55            if not isinstance(name_or_transformer, str):
56                raise ValueError("Expected string name when transformer is provided")
57            if not isinstance(transformer, _TransformLike):
58                raise ValueError("Expected object with transform method")
59            self._transformers[name_or_transformer] = transformer

Register a transformer.

Args: name_or_transformer: Either transformer instance or name string transformer: Transformer instance (when first arg is name)

def unregister(self, name: str) -> bool:
61    def unregister(self, name: str) -> bool:
62        """
63        Unregister a transformer by name.
64
65        Args:
66            name: Name of transformer to remove
67
68        Returns:
69            True if transformer was found and removed
70        """
71        if name in self._transformers:
72            del self._transformers[name]
73            return True
74        return False

Unregister a transformer by name.

Args: name: Name of transformer to remove

Returns: True if transformer was found and removed

def get_transformer(self, name: str) -> Optional[nopii.transforms.registry._TransformLike]:
76    def get_transformer(self, name: str) -> Optional[_TransformLike]:
77        """
78        Get a transformer by name.
79
80        Args:
81            name: Name of the transformer
82
83        Returns:
84            Transformer instance or None if not found
85        """
86        return self._transformers.get(name)

Get a transformer by name.

Args: name: Name of the transformer

Returns: Transformer instance or None if not found

def list_transformers(self) -> List[str]:
88    def list_transformers(self) -> List[str]:
89        """
90        List all registered transformer names.
91
92        Returns:
93            List of transformer names
94        """
95        return list(self._transformers.keys())

List all registered transformer names.

Returns: List of transformer names

def get_all_transformers(self) -> List[nopii.transforms.registry._TransformLike]:
 97    def get_all_transformers(self) -> List[_TransformLike]:
 98        """
 99        Get all registered transformers.
100
101        Returns:
102            List of all transformer instances
103        """
104        return list(self._transformers.values())

Get all registered transformers.

Returns: List of all transformer instances

def get_transformer_info( self, name: Optional[str] = None) -> Union[Dict[str, Any], List[Dict[str, Any]], NoneType]:
106    def get_transformer_info(
107        self, name: Optional[str] = None
108    ) -> Union[Dict[str, Any], List[Dict[str, Any]], None]:
109        """
110        Get information about all registered transformers or a specific transformer.
111
112        Args:
113            name: Optional name of specific transformer to get info for
114
115        Returns:
116            Transformer info dict if name provided, list of dicts otherwise, or None if not found
117        """
118        if name:
119            transformer = self.get_transformer(name)
120            if transformer:
121                info = transformer.get_info()
122                # Add supported_types field if not present
123                if "supported_types" not in info:
124                    info["supported_types"] = [
125                        "email",
126                        "phone",
127                        "ssn",
128                        "credit_card",
129                        "name",
130                        "address",
131                    ]
132                return info
133            return None
134        return [transformer.get_info() for transformer in self._transformers.values()]

Get information about all registered transformers or a specific transformer.

Args: name: Optional name of specific transformer to get info for

Returns: Transformer info dict if name provided, list of dicts otherwise, or None if not found

def transform( self, value: str, pii_type: str, transformation_type: str, options: Optional[Dict[str, Any]] = None) -> Any:
136    def transform(
137        self,
138        value: str,
139        pii_type: str,
140        transformation_type: str,
141        options: Optional[Dict[str, Any]] = None,
142    ) -> Any:
143        """
144        Transform a value using the specified transformation type.
145
146        Args:
147            value: The value to transform
148            pii_type: The type of PII (e.g., 'email', 'phone')
149            transformation_type: The type of transformation to apply
150            options: Optional transformation options
151
152        Returns:
153            TransformationResult object
154        """
155        transformer = self.get_transformer(transformation_type)
156        if not transformer:
157            from ..core.models import TransformationResult
158
159            return TransformationResult(
160                original_value=value,
161                transformed_value=value,
162                transformation_type=transformation_type,
163                pii_type=pii_type,
164                success=False,
165                error_message=f"Transformer '{transformation_type}' not found",
166            )
167
168        return transformer.transform(value, pii_type, options)

Transform a value using the specified transformation type.

Args: value: The value to transform pii_type: The type of PII (e.g., 'email', 'phone') transformation_type: The type of transformation to apply options: Optional transformation options

Returns: TransformationResult object

def batch_transform( self, values: List[str], pii_types: List[str], transformation_type: str, options: Optional[Dict[str, Any]] = None) -> List[Any]:
170    def batch_transform(
171        self,
172        values: List[str],
173        pii_types: List[str],
174        transformation_type: str,
175        options: Optional[Dict[str, Any]] = None,
176    ) -> List[Any]:
177        """
178        Transform multiple values using the specified transformation type.
179
180        Args:
181            values: List of values to transform
182            pii_types: List of PII types corresponding to each value
183            transformation_type: The type of transformation to apply
184            options: Optional transformation options
185
186        Returns:
187            List of TransformationResult objects
188        """
189        if len(values) != len(pii_types):
190            raise ValueError("Values and PII types lists must have the same length")
191
192        results = []
193        for value, pii_type in zip(values, pii_types):
194            result = self.transform(value, pii_type, transformation_type, options)
195            results.append(result)
196
197        return results

Transform multiple values using the specified transformation type.

Args: values: List of values to transform pii_types: List of PII types corresponding to each value transformation_type: The type of transformation to apply options: Optional transformation options

Returns: List of TransformationResult objects

def get_supported_transformations(self, pii_type: Optional[str] = None) -> List[str]:
199    def get_supported_transformations(
200        self, pii_type: Optional[str] = None
201    ) -> List[str]:
202        """
203        Get list of supported transformation types.
204
205        Args:
206            pii_type: Optional PII type to filter transformations for
207
208        Returns:
209            List of transformation type names
210        """
211        # For now, return all registered transformers
212        # In the future, this could be filtered by PII type
213        return list(self._transformers.keys())

Get list of supported transformation types.

Args: pii_type: Optional PII type to filter transformations for

Returns: List of transformation type names

class NoPIIClient:
 33class NoPIIClient:
 34    """
 35    Main client class for the nopii SDK.
 36
 37    Provides a high-level interface for PII detection, transformation, and reporting.
 38    Supports text, DataFrames, and file processing with automatic policy management.
 39
 40    Examples:
 41        Basic usage:
 42        >>> client = NoPIIClient()
 43        >>> findings = client.scan_text("Contact john@example.com")
 44        >>> clean_text, audit = client.transform_text("Contact john@example.com")
 45
 46        With custom policy:
 47        >>> client = NoPIIClient("my_policy.yaml")
 48        >>> df_clean, audit = client.transform_dataframe(df)
 49    """
 50
 51    def __init__(self, policy: Optional[Union[str, Path, Policy, dict]] = None):
 52        """
 53        Initialize the NoPII client.
 54
 55        Args:
 56            policy: Policy configuration. Can be:
 57                - None: Use default policy
 58                - str/Path: Path to YAML policy file
 59                - Policy: Policy object
 60                - dict: Policy configuration dictionary
 61        """
 62        self._policy = self._load_policy(policy)
 63        self._scanner = Scanner(self._policy)
 64        self._transform = Transform(self._policy)
 65
 66        # SDK wrappers
 67        self.scanner = SDKScanner(self._scanner)
 68        self.transform = SDKTransform(self._transform)
 69        self.policy = SDKPolicy(self._policy)
 70
 71        # Report generators
 72        self._html_generator = HTMLReportGenerator()
 73        self._markdown_generator = MarkdownReportGenerator()
 74        self._json_generator = JSONReportGenerator()
 75
 76    def _load_policy(self, policy: Optional[Union[str, Path, Policy, dict]]) -> Policy:
 77        """Load policy from various sources."""
 78        if policy is None:
 79            return create_default_policy()
 80        elif isinstance(policy, Policy):
 81            return policy
 82        elif isinstance(policy, dict):
 83            from ..policy.loader import load_policy_from_dict
 84
 85            return load_policy_from_dict(policy)
 86        elif isinstance(policy, (str, Path)):
 87            return load_policy(policy)
 88        else:
 89            raise ValueError(f"Invalid policy type: {type(policy)}")
 90
 91    @property
 92    def current_policy(self) -> Policy:
 93        """Get the current policy."""
 94        return self._policy
 95
 96    def update_policy(self, policy: Union[str, Path, Policy, dict]) -> None:
 97        """
 98        Update the current policy.
 99
100        Args:
101            policy: New policy configuration
102        """
103        self._policy = self._load_policy(policy)
104        self._scanner = Scanner(self._policy)
105        self._transform = Transform(self._policy)
106
107        # Update SDK wrappers
108        self.scanner._scanner = self._scanner
109        self.transform._transform = self._transform
110        self.policy._policy = self._policy
111
112    def scan_dataframe(
113        self,
114        df: Any,
115        dataset_name: Optional[str] = None,
116        confidence_threshold: float = 0.5,
117    ) -> ScanResult:
118        """
119        Scan a pandas DataFrame for PII.
120
121        Args:
122            df: DataFrame to scan
123            dataset_name: Optional name for the dataset
124            confidence_threshold: Minimum confidence threshold for findings
125
126        Returns:
127            ScanResult with detected PII
128        """
129        return self.scanner.scan_dataframe(df, dataset_name, confidence_threshold)
130
131    def scan_text(
132        self, text: str, confidence_threshold: float = 0.5
133    ) -> List[Dict[str, Any]]:
134        """
135        Scan text for PII and return findings.
136
137        Args:
138            text: Text to scan for PII
139            confidence_threshold: Minimum confidence score (0.0-1.0)
140
141        Returns:
142            List of PII findings with location, type, and confidence
143
144        Example:
145            >>> findings = client.scan_text("Call me at 555-123-4567")
146            >>> print(f"Found {len(findings)} PII items")
147        """
148        return self.scanner.scan_text(text, confidence_threshold)
149
150    def scan_file(
151        self, file_path: Union[str, Path], confidence_threshold: float = 0.5
152    ) -> ScanResult:
153        """
154        Scan a file for PII.
155
156        Args:
157            file_path: Path to file to scan
158            confidence_threshold: Minimum confidence threshold for findings
159
160        Returns:
161            ScanResult with detected PII
162        """
163        return self.scanner.scan_file(file_path, confidence_threshold)
164
165    def transform_dataframe(
166        self,
167        df: Any,
168        dataset_name: Optional[str] = None,
169        dry_run: bool = False,
170    ) -> tuple[Any, AuditReport]:
171        """
172        No PII from a pandas DataFrame.
173
174        Args:
175            df: DataFrame to transform
176            dataset_name: Optional name for the dataset
177            dry_run: If True, don't modify data but show what would be transform
178
179        Returns:
180            Tuple of (transform_dataframe, audit_report)
181        """
182        return self.transform.transform_dataframe(df, dataset_name, dry_run)
183
184    def transform_text(
185        self, text: str, dry_run: bool = False
186    ) -> tuple[str, List[Dict[str, Any]]]:
187        """
188        Transform text by applying PII transformations.
189
190        Args:
191            text: Text to transform
192            dry_run: If True, return what would be transformed without changes
193
194        Returns:
195            Tuple of (transformed_text, findings_list)
196
197        Example:
198            >>> clean_text, findings = client.transform_text("Email: john@example.com")
199            >>> print(clean_text)  # "Email: ****@example.com"
200        """
201        return self.transform.transform_text(text, dry_run)
202
203    def transform_file(
204        self,
205        input_path: Union[str, Path],
206        output_path: Optional[Union[str, Path]] = None,
207        dry_run: bool = False,
208        backup: bool = True,
209    ) -> AuditReport:
210        """
211        No PII from a file.
212
213        Args:
214            input_path: Path to input file
215            output_path: Path to output file (defaults to input_path)
216            dry_run: If True, don't modify file but show what would be transform
217            backup: If True, create backup of original file
218
219        Returns:
220            AuditReport with transformation details
221        """
222        return self.transform.transform_file(input_path, output_path, dry_run, backup)
223
224    def generate_report(
225        self,
226        audit_report: AuditReport,
227        format_type: str = "html",
228        output_path: Optional[Union[str, Path]] = None,
229        template_name: str = "default",
230        include_samples: bool = False,
231        format: Optional[str] = None,
232        **kwargs,
233    ) -> str:
234        """
235        Generate an audit report.
236
237        Args:
238            audit_report: Audit report to generate from
239            format_type: Output format ('html', 'markdown', 'json')
240            output_path: Optional path to save report
241            template_name: Template to use
242            include_samples: Whether to include PII samples
243            **kwargs: Additional template context
244
245        Returns:
246            Generated report content
247        """
248        output_path = Path(output_path) if output_path else None
249
250        fmt = (format or format_type or "html").lower()
251        if fmt == "html":
252            return self._html_generator.generate(
253                audit_report, output_path, template_name, include_samples, **kwargs
254            )
255        elif fmt == "markdown":
256            return self._markdown_generator.generate(
257                audit_report, output_path, template_name, include_samples, **kwargs
258            )
259        elif fmt == "json":
260            return self._json_generator.generate(
261                audit_report, output_path, include_samples, **kwargs
262            )
263        else:
264            raise ValueError(f"Unsupported format type: {fmt}")
265
266    def quick_scan(
267        self, data: Union[pd.DataFrame, str, Path], confidence_threshold: float = 0.5
268    ) -> Dict[str, Any]:
269        """
270        Perform a quick scan and return summary results.
271
272        Args:
273            data: Data to scan (DataFrame, text, or file path)
274            confidence_threshold: Minimum confidence threshold
275
276        Returns:
277            Dictionary with scan summary
278        """
279        if isinstance(data, pd.DataFrame):
280            result = self.scan_dataframe(
281                data, confidence_threshold=confidence_threshold
282            )
283            return self._summary_from_scan_result(result)
284        elif isinstance(data, str):
285            if Path(data).exists():
286                # It's a file path
287                result = self.scan_file(data, confidence_threshold=confidence_threshold)
288                return self._summary_from_scan_result(result)
289            else:
290                # It's text content
291                findings = self.scan_text(
292                    data, confidence_threshold=confidence_threshold
293                )
294                return self._summary_from_findings_dicts(findings)
295        elif isinstance(data, Path):
296            result = self.scan_file(data, confidence_threshold=confidence_threshold)
297            return self._summary_from_scan_result(result)
298        else:
299            raise ValueError(f"Unsupported data type: {type(data)}")
300
301    def _summary_from_scan_result(self, result: ScanResult) -> Dict[str, Any]:
302        """Build a summary dict from a ScanResult."""
303        return {
304            "total_findings": len(result.findings),
305            "pii_types": list({f.type for f in result.findings}),
306            "affected_columns": list({f.column for f in result.findings if f.column}),
307            "coverage_score": result.coverage_score,
308            "high_confidence_findings": len(
309                [f for f in result.findings if f.confidence >= 0.8]
310            ),
311        }
312
313    def _summary_from_findings_dicts(
314        self, findings: List[Dict[str, Any]]
315    ) -> Dict[str, Any]:
316        """Build a summary dict from a list of finding dictionaries."""
317        return {
318            "total_findings": len(findings),
319            "pii_types": list({f.get("type") for f in findings}),
320            "high_confidence_findings": len(
321                [f for f in findings if f.get("confidence", 0.0) >= 0.8]
322            ),
323        }
324
325    def quick_transform(
326        self, data: Union[pd.DataFrame, str], dry_run: bool = False
327    ) -> Union[pd.DataFrame, str]:
328        """
329        Perform quick transformation and return the transform data.
330
331        Args:
332            data: Data to transform (DataFrame or text)
333            dry_run: If True, don't modify data but show what would be transform
334
335        Returns:
336            TRANSFORM data
337        """
338        if isinstance(data, pd.DataFrame):
339            transform_df, _ = self.transform_dataframe(data, dry_run=dry_run)
340            return transform_df
341        elif isinstance(data, str):
342            transform_text, _ = self.transform_text(data, dry_run=dry_run)
343            return transform_text
344        else:
345            raise ValueError(f"Unsupported data type: {type(data)}")
346
347    def get_policy_info(self) -> Dict[str, Any]:
348        """
349        Get information about the current policy.
350
351        Returns:
352            Dictionary with policy information
353        """
354        return self.policy.get_info()
355
356    def list_detectors(self) -> List[Dict[str, Any]]:
357        """
358        List available PII detectors.
359
360        Returns:
361            List of detector information
362        """
363        return self.scanner.list_detectors()
364
365    def list_transformers(self) -> List[Dict[str, Any]]:
366        """
367        List available transformers.
368
369        Returns:
370            List of transformer information
371        """
372        return self.transform.list_transformers()
373
374    def validate_policy(self) -> Dict[str, Any]:
375        """
376        Validate the current policy.
377
378        Returns:
379            Dictionary with validation results
380        """
381        return self.policy.validate()
382
383    def __repr__(self) -> str:
384        """String representation of the client."""
385        return f"NoPIIClient(policy='{self._policy.name}')"

Main client class for the nopii SDK.

Provides a high-level interface for PII detection, transformation, and reporting. Supports text, DataFrames, and file processing with automatic policy management.

Examples: Basic usage:

client = NoPIIClient() findings = client.scan_text("Contact john@example.com") clean_text, audit = client.transform_text("Contact john@example.com")

With custom policy:
>>> client = NoPIIClient("my_policy.yaml")
>>> df_clean, audit = client.transform_dataframe(df)
NoPIIClient( policy: Union[str, pathlib.Path, Policy, dict, NoneType] = None)
51    def __init__(self, policy: Optional[Union[str, Path, Policy, dict]] = None):
52        """
53        Initialize the NoPII client.
54
55        Args:
56            policy: Policy configuration. Can be:
57                - None: Use default policy
58                - str/Path: Path to YAML policy file
59                - Policy: Policy object
60                - dict: Policy configuration dictionary
61        """
62        self._policy = self._load_policy(policy)
63        self._scanner = Scanner(self._policy)
64        self._transform = Transform(self._policy)
65
66        # SDK wrappers
67        self.scanner = SDKScanner(self._scanner)
68        self.transform = SDKTransform(self._transform)
69        self.policy = SDKPolicy(self._policy)
70
71        # Report generators
72        self._html_generator = HTMLReportGenerator()
73        self._markdown_generator = MarkdownReportGenerator()
74        self._json_generator = JSONReportGenerator()

Initialize the NoPII client.

Args: policy: Policy configuration. Can be: - None: Use default policy - str/Path: Path to YAML policy file - Policy: Policy object - dict: Policy configuration dictionary

scanner
transform
policy
current_policy: Policy
91    @property
92    def current_policy(self) -> Policy:
93        """Get the current policy."""
94        return self._policy

Get the current policy.

def update_policy( self, policy: Union[str, pathlib.Path, Policy, dict]) -> None:
 96    def update_policy(self, policy: Union[str, Path, Policy, dict]) -> None:
 97        """
 98        Update the current policy.
 99
100        Args:
101            policy: New policy configuration
102        """
103        self._policy = self._load_policy(policy)
104        self._scanner = Scanner(self._policy)
105        self._transform = Transform(self._policy)
106
107        # Update SDK wrappers
108        self.scanner._scanner = self._scanner
109        self.transform._transform = self._transform
110        self.policy._policy = self._policy

Update the current policy.

Args: policy: New policy configuration

def scan_dataframe( self, df: Any, dataset_name: Optional[str] = None, confidence_threshold: float = 0.5) -> ScanResult:
112    def scan_dataframe(
113        self,
114        df: Any,
115        dataset_name: Optional[str] = None,
116        confidence_threshold: float = 0.5,
117    ) -> ScanResult:
118        """
119        Scan a pandas DataFrame for PII.
120
121        Args:
122            df: DataFrame to scan
123            dataset_name: Optional name for the dataset
124            confidence_threshold: Minimum confidence threshold for findings
125
126        Returns:
127            ScanResult with detected PII
128        """
129        return self.scanner.scan_dataframe(df, dataset_name, confidence_threshold)

Scan a pandas DataFrame for PII.

Args: df: DataFrame to scan dataset_name: Optional name for the dataset confidence_threshold: Minimum confidence threshold for findings

Returns: ScanResult with detected PII

def scan_text( self, text: str, confidence_threshold: float = 0.5) -> List[Dict[str, Any]]:
131    def scan_text(
132        self, text: str, confidence_threshold: float = 0.5
133    ) -> List[Dict[str, Any]]:
134        """
135        Scan text for PII and return findings.
136
137        Args:
138            text: Text to scan for PII
139            confidence_threshold: Minimum confidence score (0.0-1.0)
140
141        Returns:
142            List of PII findings with location, type, and confidence
143
144        Example:
145            >>> findings = client.scan_text("Call me at 555-123-4567")
146            >>> print(f"Found {len(findings)} PII items")
147        """
148        return self.scanner.scan_text(text, confidence_threshold)

Scan text for PII and return findings.

Args: text: Text to scan for PII confidence_threshold: Minimum confidence score (0.0-1.0)

Returns: List of PII findings with location, type, and confidence

Example:

findings = client.scan_text("Call me at 555-123-4567") print(f"Found {len(findings)} PII items")

def scan_file( self, file_path: Union[str, pathlib.Path], confidence_threshold: float = 0.5) -> ScanResult:
150    def scan_file(
151        self, file_path: Union[str, Path], confidence_threshold: float = 0.5
152    ) -> ScanResult:
153        """
154        Scan a file for PII.
155
156        Args:
157            file_path: Path to file to scan
158            confidence_threshold: Minimum confidence threshold for findings
159
160        Returns:
161            ScanResult with detected PII
162        """
163        return self.scanner.scan_file(file_path, confidence_threshold)

Scan a file for PII.

Args: file_path: Path to file to scan confidence_threshold: Minimum confidence threshold for findings

Returns: ScanResult with detected PII

def transform_dataframe( self, df: Any, dataset_name: Optional[str] = None, dry_run: bool = False) -> tuple[typing.Any, AuditReport]:
165    def transform_dataframe(
166        self,
167        df: Any,
168        dataset_name: Optional[str] = None,
169        dry_run: bool = False,
170    ) -> tuple[Any, AuditReport]:
171        """
172        No PII from a pandas DataFrame.
173
174        Args:
175            df: DataFrame to transform
176            dataset_name: Optional name for the dataset
177            dry_run: If True, don't modify data but show what would be transform
178
179        Returns:
180            Tuple of (transform_dataframe, audit_report)
181        """
182        return self.transform.transform_dataframe(df, dataset_name, dry_run)

No PII from a pandas DataFrame.

Args: df: DataFrame to transform dataset_name: Optional name for the dataset dry_run: If True, don't modify data but show what would be transform

Returns: Tuple of (transform_dataframe, audit_report)

def transform_text( self, text: str, dry_run: bool = False) -> tuple[str, typing.List[typing.Dict[str, typing.Any]]]:
184    def transform_text(
185        self, text: str, dry_run: bool = False
186    ) -> tuple[str, List[Dict[str, Any]]]:
187        """
188        Transform text by applying PII transformations.
189
190        Args:
191            text: Text to transform
192            dry_run: If True, return what would be transformed without changes
193
194        Returns:
195            Tuple of (transformed_text, findings_list)
196
197        Example:
198            >>> clean_text, findings = client.transform_text("Email: john@example.com")
199            >>> print(clean_text)  # "Email: ****@example.com"
200        """
201        return self.transform.transform_text(text, dry_run)

Transform text by applying PII transformations.

Args: text: Text to transform dry_run: If True, return what would be transformed without changes

Returns: Tuple of (transformed_text, findings_list)

Example:

clean_text, findings = client.transform_text("Email: john@example.com") print(clean_text) # "Email: ****@example.com"

def transform_file( self, input_path: Union[str, pathlib.Path], output_path: Union[str, pathlib.Path, NoneType] = None, dry_run: bool = False, backup: bool = True) -> AuditReport:
203    def transform_file(
204        self,
205        input_path: Union[str, Path],
206        output_path: Optional[Union[str, Path]] = None,
207        dry_run: bool = False,
208        backup: bool = True,
209    ) -> AuditReport:
210        """
211        No PII from a file.
212
213        Args:
214            input_path: Path to input file
215            output_path: Path to output file (defaults to input_path)
216            dry_run: If True, don't modify file but show what would be transform
217            backup: If True, create backup of original file
218
219        Returns:
220            AuditReport with transformation details
221        """
222        return self.transform.transform_file(input_path, output_path, dry_run, backup)

No PII from a file.

Args: input_path: Path to input file output_path: Path to output file (defaults to input_path) dry_run: If True, don't modify file but show what would be transform backup: If True, create backup of original file

Returns: AuditReport with transformation details

def generate_report( self, audit_report: AuditReport, format_type: str = 'html', output_path: Union[str, pathlib.Path, NoneType] = None, template_name: str = 'default', include_samples: bool = False, format: Optional[str] = None, **kwargs) -> str:
224    def generate_report(
225        self,
226        audit_report: AuditReport,
227        format_type: str = "html",
228        output_path: Optional[Union[str, Path]] = None,
229        template_name: str = "default",
230        include_samples: bool = False,
231        format: Optional[str] = None,
232        **kwargs,
233    ) -> str:
234        """
235        Generate an audit report.
236
237        Args:
238            audit_report: Audit report to generate from
239            format_type: Output format ('html', 'markdown', 'json')
240            output_path: Optional path to save report
241            template_name: Template to use
242            include_samples: Whether to include PII samples
243            **kwargs: Additional template context
244
245        Returns:
246            Generated report content
247        """
248        output_path = Path(output_path) if output_path else None
249
250        fmt = (format or format_type or "html").lower()
251        if fmt == "html":
252            return self._html_generator.generate(
253                audit_report, output_path, template_name, include_samples, **kwargs
254            )
255        elif fmt == "markdown":
256            return self._markdown_generator.generate(
257                audit_report, output_path, template_name, include_samples, **kwargs
258            )
259        elif fmt == "json":
260            return self._json_generator.generate(
261                audit_report, output_path, include_samples, **kwargs
262            )
263        else:
264            raise ValueError(f"Unsupported format type: {fmt}")

Generate an audit report.

Args: audit_report: Audit report to generate from format_type: Output format ('html', 'markdown', 'json') output_path: Optional path to save report template_name: Template to use include_samples: Whether to include PII samples **kwargs: Additional template context

Returns: Generated report content

def quick_scan( self, data: Union[pandas.DataFrame, str, pathlib.Path], confidence_threshold: float = 0.5) -> Dict[str, Any]:
266    def quick_scan(
267        self, data: Union[pd.DataFrame, str, Path], confidence_threshold: float = 0.5
268    ) -> Dict[str, Any]:
269        """
270        Perform a quick scan and return summary results.
271
272        Args:
273            data: Data to scan (DataFrame, text, or file path)
274            confidence_threshold: Minimum confidence threshold
275
276        Returns:
277            Dictionary with scan summary
278        """
279        if isinstance(data, pd.DataFrame):
280            result = self.scan_dataframe(
281                data, confidence_threshold=confidence_threshold
282            )
283            return self._summary_from_scan_result(result)
284        elif isinstance(data, str):
285            if Path(data).exists():
286                # It's a file path
287                result = self.scan_file(data, confidence_threshold=confidence_threshold)
288                return self._summary_from_scan_result(result)
289            else:
290                # It's text content
291                findings = self.scan_text(
292                    data, confidence_threshold=confidence_threshold
293                )
294                return self._summary_from_findings_dicts(findings)
295        elif isinstance(data, Path):
296            result = self.scan_file(data, confidence_threshold=confidence_threshold)
297            return self._summary_from_scan_result(result)
298        else:
299            raise ValueError(f"Unsupported data type: {type(data)}")

Perform a quick scan and return summary results.

Args: data: Data to scan (DataFrame, text, or file path) confidence_threshold: Minimum confidence threshold

Returns: Dictionary with scan summary

def quick_transform( self, data: Union[pandas.DataFrame, str], dry_run: bool = False) -> Union[pandas.DataFrame, str]:
325    def quick_transform(
326        self, data: Union[pd.DataFrame, str], dry_run: bool = False
327    ) -> Union[pd.DataFrame, str]:
328        """
329        Perform quick transformation and return the transform data.
330
331        Args:
332            data: Data to transform (DataFrame or text)
333            dry_run: If True, don't modify data but show what would be transform
334
335        Returns:
336            TRANSFORM data
337        """
338        if isinstance(data, pd.DataFrame):
339            transform_df, _ = self.transform_dataframe(data, dry_run=dry_run)
340            return transform_df
341        elif isinstance(data, str):
342            transform_text, _ = self.transform_text(data, dry_run=dry_run)
343            return transform_text
344        else:
345            raise ValueError(f"Unsupported data type: {type(data)}")

Perform quick transformation and return the transform data.

Args: data: Data to transform (DataFrame or text) dry_run: If True, don't modify data but show what would be transform

Returns: TRANSFORM data

def get_policy_info(self) -> Dict[str, Any]:
347    def get_policy_info(self) -> Dict[str, Any]:
348        """
349        Get information about the current policy.
350
351        Returns:
352            Dictionary with policy information
353        """
354        return self.policy.get_info()

Get information about the current policy.

Returns: Dictionary with policy information

def list_detectors(self) -> List[Dict[str, Any]]:
356    def list_detectors(self) -> List[Dict[str, Any]]:
357        """
358        List available PII detectors.
359
360        Returns:
361            List of detector information
362        """
363        return self.scanner.list_detectors()

List available PII detectors.

Returns: List of detector information

def list_transformers(self) -> List[Dict[str, Any]]:
365    def list_transformers(self) -> List[Dict[str, Any]]:
366        """
367        List available transformers.
368
369        Returns:
370            List of transformer information
371        """
372        return self.transform.list_transformers()

List available transformers.

Returns: List of transformer information

def validate_policy(self) -> Dict[str, Any]:
374    def validate_policy(self) -> Dict[str, Any]:
375        """
376        Validate the current policy.
377
378        Returns:
379            Dictionary with validation results
380        """
381        return self.policy.validate()

Validate the current policy.

Returns: Dictionary with validation results

class SDKScanner:
 21class SDKScanner:
 22    """
 23    SDK wrapper for the Scanner class.
 24
 25    Provides a simplified interface for PII scanning operations.
 26    """
 27
 28    def __init__(self, scanner: Optional[Scanner] = None):
 29        """
 30        Initialize the SDK scanner.
 31
 32        Args:
 33            scanner: Core Scanner instance
 34        """
 35        self._scanner = scanner or Scanner(create_default_policy())
 36
 37    def scan_dataframe(
 38        self,
 39        df: pd.DataFrame,
 40        dataset_name: Optional[str] = None,
 41        confidence_threshold: float = 0.5,
 42    ) -> ScanResult:
 43        """
 44        Scan a pandas DataFrame for PII.
 45
 46        Args:
 47            df: DataFrame to scan
 48            dataset_name: Optional name for the dataset
 49            confidence_threshold: Minimum confidence threshold for findings
 50
 51        Returns:
 52            ScanResult with detected PII
 53        """
 54        return self._scanner.scan_dataframe(
 55            df, dataset_name or "dataframe", confidence_threshold
 56        )
 57
 58    def scan_text(
 59        self, text: str, confidence_threshold: float = 0.5
 60    ) -> List[Dict[str, Any]]:
 61        """
 62        Scan text for PII.
 63
 64        Args:
 65            text: Text to scan
 66            confidence_threshold: Minimum confidence threshold for findings
 67
 68        Returns:
 69            List of findings as dictionaries
 70        """
 71        findings = self._scanner.scan_text(text, confidence_threshold)
 72
 73        # Convert to dictionaries for easier SDK usage
 74        return [
 75            {
 76                "type": f.type,
 77                "value": f.value,
 78                "confidence": f.confidence,
 79                "start_pos": f.span[0],
 80                "end_pos": f.span[1],
 81                "column": "text",
 82                "row_index": 0,
 83                "evidence": f.evidence,
 84            }
 85            for f in findings
 86        ]
 87
 88    def scan_file(
 89        self, file_path: Union[str, Path], confidence_threshold: float = 0.5
 90    ) -> ScanResult:
 91        """
 92        Scan a file for PII.
 93
 94        Args:
 95            file_path: Path to file to scan
 96            confidence_threshold: Minimum confidence threshold for findings
 97
 98        Returns:
 99            ScanResult with detected PII
100        """
101        file_path = Path(file_path)
102
103        if not file_path.exists():
104            raise FileNotFoundError(f"File not found: {file_path}")
105
106        # Load file based on extension
107        if file_path.suffix.lower() == ".csv":
108            # Use core streaming scanner to avoid loading entire file
109            return self._scanner.scan_file(file_path, confidence_threshold)
110        elif file_path.suffix.lower() == ".json":
111            if pd is None:
112                raise RuntimeError(
113                    "pandas is required to scan JSON files. Install pandas."
114                )
115            df = pd.read_json(file_path)
116            return self.scan_dataframe(df, file_path.stem, confidence_threshold)
117        elif file_path.suffix.lower() == ".parquet":
118            if pd is None:
119                raise RuntimeError(
120                    "pandas is required to scan Parquet files. Install pandas."
121                )
122            df = pd.read_parquet(file_path)
123            return self.scan_dataframe(df, file_path.stem, confidence_threshold)
124        elif file_path.suffix.lower() in [".txt", ".md"]:
125            # Use core streaming scanner for text files (line-by-line)
126            return self._scanner.scan_file(file_path, confidence_threshold)
127        else:
128            raise ValueError(f"Unsupported file format: {file_path.suffix}")
129
130    def scan_dictionary(
131        self, data: Dict[str, Any], confidence_threshold: float = 0.5
132    ) -> List[Dict[str, Any]]:
133        """
134        Scan a dictionary for PII.
135
136        Args:
137            data: Dictionary to scan
138            confidence_threshold: Minimum confidence threshold for findings
139
140        Returns:
141            List of findings as dictionaries
142        """
143        findings = self._scanner.scan_dict(data, confidence_threshold)
144
145        # Convert to dictionaries for easier SDK usage
146        return [
147            {
148                "type": f.type,
149                "value": f.value,
150                "confidence": f.confidence,
151                "key": f.column,
152                "row_index": f.row_index,
153                "span": f.span,
154            }
155            for f in findings
156        ]
157
158    def get_coverage_score(self, scan_result: ScanResult) -> Dict[str, Any]:
159        """Calculate detection coverage metrics for a scan result."""
160        from ..reporting.coverage import CoverageCalculator
161
162        calc = CoverageCalculator()
163        # We don't know total cells here; let calculator derive available metrics
164        return calc.calculate_detection_coverage(scan_result)
165
166    def list_detectors(self) -> List[Dict[str, Any]]:
167        """
168        List available PII detectors.
169
170        Returns:
171            List of detector information
172        """
173        registry = self._scanner.detector_registry
174        names = registry.list_detectors()
175        out: List[Dict[str, Any]] = []
176        for name in names:
177            det = registry.get_detector(name)
178            if not det:
179                continue
180            info = det.get_info()
181            out.append(
182                {
183                    "name": info.get("name", name),
184                    "pii_type": info.get("pii_type", name),
185                    "description": info.get("description", ""),
186                }
187            )
188        return out
189
190    def get_detector_info(self, detector_name: str) -> Dict[str, Any]:
191        """
192        Get information about a specific detector.
193
194        Args:
195            detector_name: Name of the detector
196
197        Returns:
198            Dictionary with detector information
199        """
200        detector = self._scanner.detector_registry.get_detector(detector_name)
201        if not detector:
202            raise ValueError(f"Detector not found: {detector_name}")
203
204        return detector.get_info()
205
206    def test_detector(
207        self, detector_name: str, test_data: Union[str, List[str]]
208    ) -> List[Dict[str, Any]]:
209        """
210        Test a specific detector against sample data.
211
212        Args:
213            detector_name: Name of the detector to test
214            test_data: Sample data to test
215
216        Returns:
217            List of findings from the detector
218        """
219        detector = self._scanner.detector_registry.get_detector(detector_name)
220        if not detector:
221            raise ValueError(f"Detector not found: {detector_name}")
222
223        inputs = [test_data] if isinstance(test_data, str) else list(test_data)
224        results: List[Dict[str, Any]] = []
225        for s in inputs:
226            matches = detector.detect(s)
227            results.append(
228                {
229                    "input": s,
230                    "detected": bool(matches),
231                    "matches": matches,
232                }
233            )
234        return results
235
236    def analyze_findings(self, findings: List[Finding]) -> Dict[str, Any]:
237        """
238        Analyze a list of findings and provide statistics.
239
240        Args:
241            findings: List of findings to analyze
242
243        Returns:
244            Dictionary with analysis results
245        """
246        if not findings:
247            return {
248                "total_findings": 0,
249                "unique_types": 0,
250                "average_confidence": 0.0,
251                "high_confidence_count": 0,
252                "type_distribution": {},
253            }
254
255        # Calculate statistics
256        total_findings = len(findings)
257        unique_types = len(set(f.type for f in findings))
258        average_confidence = sum(f.confidence for f in findings) / total_findings
259        high_confidence_count = len([f for f in findings if f.confidence >= 0.8])
260
261        # Type distribution
262        type_distribution: Dict[str, int] = {}
263        for finding in findings:
264            type_distribution[finding.type] = type_distribution.get(finding.type, 0) + 1
265
266        return {
267            "total_findings": total_findings,
268            "unique_types": unique_types,
269            "average_confidence": round(average_confidence, 3),
270            "high_confidence_count": high_confidence_count,
271            "high_confidence_percentage": round(
272                (high_confidence_count / total_findings) * 100, 1
273            ),
274            "type_distribution": type_distribution,
275            "most_common_type": max(type_distribution.items(), key=lambda x: x[1])[0]
276            if type_distribution
277            else None,
278        }
279
280    def __repr__(self) -> str:
281        """String representation of the SDK scanner."""
282        return f"SDKScanner(detectors={len(self._scanner.detector_registry.list_detectors())})"

SDK wrapper for the Scanner class.

Provides a simplified interface for PII scanning operations.

SDKScanner(scanner: Optional[Scanner] = None)
28    def __init__(self, scanner: Optional[Scanner] = None):
29        """
30        Initialize the SDK scanner.
31
32        Args:
33            scanner: Core Scanner instance
34        """
35        self._scanner = scanner or Scanner(create_default_policy())

Initialize the SDK scanner.

Args: scanner: Core Scanner instance

def scan_dataframe( self, df: pandas.DataFrame, dataset_name: Optional[str] = None, confidence_threshold: float = 0.5) -> ScanResult:
37    def scan_dataframe(
38        self,
39        df: pd.DataFrame,
40        dataset_name: Optional[str] = None,
41        confidence_threshold: float = 0.5,
42    ) -> ScanResult:
43        """
44        Scan a pandas DataFrame for PII.
45
46        Args:
47            df: DataFrame to scan
48            dataset_name: Optional name for the dataset
49            confidence_threshold: Minimum confidence threshold for findings
50
51        Returns:
52            ScanResult with detected PII
53        """
54        return self._scanner.scan_dataframe(
55            df, dataset_name or "dataframe", confidence_threshold
56        )

Scan a pandas DataFrame for PII.

Args: df: DataFrame to scan dataset_name: Optional name for the dataset confidence_threshold: Minimum confidence threshold for findings

Returns: ScanResult with detected PII

def scan_text( self, text: str, confidence_threshold: float = 0.5) -> List[Dict[str, Any]]:
58    def scan_text(
59        self, text: str, confidence_threshold: float = 0.5
60    ) -> List[Dict[str, Any]]:
61        """
62        Scan text for PII.
63
64        Args:
65            text: Text to scan
66            confidence_threshold: Minimum confidence threshold for findings
67
68        Returns:
69            List of findings as dictionaries
70        """
71        findings = self._scanner.scan_text(text, confidence_threshold)
72
73        # Convert to dictionaries for easier SDK usage
74        return [
75            {
76                "type": f.type,
77                "value": f.value,
78                "confidence": f.confidence,
79                "start_pos": f.span[0],
80                "end_pos": f.span[1],
81                "column": "text",
82                "row_index": 0,
83                "evidence": f.evidence,
84            }
85            for f in findings
86        ]

Scan text for PII.

Args: text: Text to scan confidence_threshold: Minimum confidence threshold for findings

Returns: List of findings as dictionaries

def scan_file( self, file_path: Union[str, pathlib.Path], confidence_threshold: float = 0.5) -> ScanResult:
 88    def scan_file(
 89        self, file_path: Union[str, Path], confidence_threshold: float = 0.5
 90    ) -> ScanResult:
 91        """
 92        Scan a file for PII.
 93
 94        Args:
 95            file_path: Path to file to scan
 96            confidence_threshold: Minimum confidence threshold for findings
 97
 98        Returns:
 99            ScanResult with detected PII
100        """
101        file_path = Path(file_path)
102
103        if not file_path.exists():
104            raise FileNotFoundError(f"File not found: {file_path}")
105
106        # Load file based on extension
107        if file_path.suffix.lower() == ".csv":
108            # Use core streaming scanner to avoid loading entire file
109            return self._scanner.scan_file(file_path, confidence_threshold)
110        elif file_path.suffix.lower() == ".json":
111            if pd is None:
112                raise RuntimeError(
113                    "pandas is required to scan JSON files. Install pandas."
114                )
115            df = pd.read_json(file_path)
116            return self.scan_dataframe(df, file_path.stem, confidence_threshold)
117        elif file_path.suffix.lower() == ".parquet":
118            if pd is None:
119                raise RuntimeError(
120                    "pandas is required to scan Parquet files. Install pandas."
121                )
122            df = pd.read_parquet(file_path)
123            return self.scan_dataframe(df, file_path.stem, confidence_threshold)
124        elif file_path.suffix.lower() in [".txt", ".md"]:
125            # Use core streaming scanner for text files (line-by-line)
126            return self._scanner.scan_file(file_path, confidence_threshold)
127        else:
128            raise ValueError(f"Unsupported file format: {file_path.suffix}")

Scan a file for PII.

Args: file_path: Path to file to scan confidence_threshold: Minimum confidence threshold for findings

Returns: ScanResult with detected PII

def scan_dictionary( self, data: Dict[str, Any], confidence_threshold: float = 0.5) -> List[Dict[str, Any]]:
130    def scan_dictionary(
131        self, data: Dict[str, Any], confidence_threshold: float = 0.5
132    ) -> List[Dict[str, Any]]:
133        """
134        Scan a dictionary for PII.
135
136        Args:
137            data: Dictionary to scan
138            confidence_threshold: Minimum confidence threshold for findings
139
140        Returns:
141            List of findings as dictionaries
142        """
143        findings = self._scanner.scan_dict(data, confidence_threshold)
144
145        # Convert to dictionaries for easier SDK usage
146        return [
147            {
148                "type": f.type,
149                "value": f.value,
150                "confidence": f.confidence,
151                "key": f.column,
152                "row_index": f.row_index,
153                "span": f.span,
154            }
155            for f in findings
156        ]

Scan a dictionary for PII.

Args: data: Dictionary to scan confidence_threshold: Minimum confidence threshold for findings

Returns: List of findings as dictionaries

def get_coverage_score(self, scan_result: ScanResult) -> Dict[str, Any]:
158    def get_coverage_score(self, scan_result: ScanResult) -> Dict[str, Any]:
159        """Calculate detection coverage metrics for a scan result."""
160        from ..reporting.coverage import CoverageCalculator
161
162        calc = CoverageCalculator()
163        # We don't know total cells here; let calculator derive available metrics
164        return calc.calculate_detection_coverage(scan_result)

Calculate detection coverage metrics for a scan result.

def list_detectors(self) -> List[Dict[str, Any]]:
166    def list_detectors(self) -> List[Dict[str, Any]]:
167        """
168        List available PII detectors.
169
170        Returns:
171            List of detector information
172        """
173        registry = self._scanner.detector_registry
174        names = registry.list_detectors()
175        out: List[Dict[str, Any]] = []
176        for name in names:
177            det = registry.get_detector(name)
178            if not det:
179                continue
180            info = det.get_info()
181            out.append(
182                {
183                    "name": info.get("name", name),
184                    "pii_type": info.get("pii_type", name),
185                    "description": info.get("description", ""),
186                }
187            )
188        return out

List available PII detectors.

Returns: List of detector information

def get_detector_info(self, detector_name: str) -> Dict[str, Any]:
190    def get_detector_info(self, detector_name: str) -> Dict[str, Any]:
191        """
192        Get information about a specific detector.
193
194        Args:
195            detector_name: Name of the detector
196
197        Returns:
198            Dictionary with detector information
199        """
200        detector = self._scanner.detector_registry.get_detector(detector_name)
201        if not detector:
202            raise ValueError(f"Detector not found: {detector_name}")
203
204        return detector.get_info()

Get information about a specific detector.

Args: detector_name: Name of the detector

Returns: Dictionary with detector information

def test_detector( self, detector_name: str, test_data: Union[str, List[str]]) -> List[Dict[str, Any]]:
206    def test_detector(
207        self, detector_name: str, test_data: Union[str, List[str]]
208    ) -> List[Dict[str, Any]]:
209        """
210        Test a specific detector against sample data.
211
212        Args:
213            detector_name: Name of the detector to test
214            test_data: Sample data to test
215
216        Returns:
217            List of findings from the detector
218        """
219        detector = self._scanner.detector_registry.get_detector(detector_name)
220        if not detector:
221            raise ValueError(f"Detector not found: {detector_name}")
222
223        inputs = [test_data] if isinstance(test_data, str) else list(test_data)
224        results: List[Dict[str, Any]] = []
225        for s in inputs:
226            matches = detector.detect(s)
227            results.append(
228                {
229                    "input": s,
230                    "detected": bool(matches),
231                    "matches": matches,
232                }
233            )
234        return results

Test a specific detector against sample data.

Args: detector_name: Name of the detector to test test_data: Sample data to test

Returns: List of findings from the detector

def analyze_findings(self, findings: List[Finding]) -> Dict[str, Any]:
236    def analyze_findings(self, findings: List[Finding]) -> Dict[str, Any]:
237        """
238        Analyze a list of findings and provide statistics.
239
240        Args:
241            findings: List of findings to analyze
242
243        Returns:
244            Dictionary with analysis results
245        """
246        if not findings:
247            return {
248                "total_findings": 0,
249                "unique_types": 0,
250                "average_confidence": 0.0,
251                "high_confidence_count": 0,
252                "type_distribution": {},
253            }
254
255        # Calculate statistics
256        total_findings = len(findings)
257        unique_types = len(set(f.type for f in findings))
258        average_confidence = sum(f.confidence for f in findings) / total_findings
259        high_confidence_count = len([f for f in findings if f.confidence >= 0.8])
260
261        # Type distribution
262        type_distribution: Dict[str, int] = {}
263        for finding in findings:
264            type_distribution[finding.type] = type_distribution.get(finding.type, 0) + 1
265
266        return {
267            "total_findings": total_findings,
268            "unique_types": unique_types,
269            "average_confidence": round(average_confidence, 3),
270            "high_confidence_count": high_confidence_count,
271            "high_confidence_percentage": round(
272                (high_confidence_count / total_findings) * 100, 1
273            ),
274            "type_distribution": type_distribution,
275            "most_common_type": max(type_distribution.items(), key=lambda x: x[1])[0]
276            if type_distribution
277            else None,
278        }

Analyze a list of findings and provide statistics.

Args: findings: List of findings to analyze

Returns: Dictionary with analysis results

class SDKTransform:
 21class SDKTransform:
 22    """
 23    SDK wrapper for the Transform class.
 24
 25    Provides a simplified interface for PII transformation operations.
 26    """
 27
 28    def __init__(self, transform: Optional[Transform] = None):
 29        """
 30        Initialize the SDK transform.
 31
 32        Args:
 33            transform: Core TRANSFORM instance
 34        """
 35        self._transform = transform or Transform(create_default_policy())
 36
 37    def transform_dataframe(
 38        self,
 39        df: pd.DataFrame,
 40        dataset_name: Optional[str] = None,
 41        dry_run: bool = False,
 42    ) -> Tuple[pd.DataFrame, AuditReport]:
 43        """
 44        No PII from a pandas DataFrame.
 45
 46        Args:
 47            df: DataFrame to transform
 48            dataset_name: Optional name for the dataset
 49            dry_run: If True, don't modify data but show what would be transform
 50
 51        Returns:
 52            Tuple of (transform_dataframe, audit_report)
 53        """
 54        return self._transform.transform_dataframe(
 55            df, dataset_name or "dataframe", dry_run=dry_run
 56        )
 57
 58    def transform_text(
 59        self, text: str, dry_run: bool = False
 60    ) -> Tuple[str, List[Dict[str, Any]]]:
 61        """
 62        No PII from text.
 63
 64        Args:
 65            text: Text to transform
 66            dry_run: If True, don't modify text but show what would be transform
 67
 68        Returns:
 69            Tuple of (transform_text, findings_list)
 70        """
 71        transform_text, findings = self._transform.transform_text(text, dry_run=dry_run)
 72
 73        # Convert findings to dictionaries for easier SDK usage
 74        findings_dict = [
 75            {
 76                "type": f.type,
 77                "original_value": f.value,
 78                "transformed_value": f.transformed_value,
 79                "confidence": f.confidence,
 80                "start_pos": f.span[0],
 81                "end_pos": f.span[1],
 82                "action_taken": f.action_taken,
 83                "column": "text",
 84                "row_index": 0,
 85                "evidence": f.evidence,
 86            }
 87            for f in findings
 88        ]
 89
 90        return transform_text, findings_dict
 91
 92    def transform_file(
 93        self,
 94        input_path: Union[str, Path],
 95        output_path: Optional[Union[str, Path]] = None,
 96        dry_run: bool = False,
 97        backup: bool = True,
 98    ) -> AuditReport:
 99        """
100        No PII from a file.
101
102        Args:
103            input_path: Path to input file
104            output_path: Path to output file (defaults to input_path)
105            dry_run: If True, don't modify file but show what would be transform
106            backup: If True, create backup of original file
107
108        Returns:
109            AuditReport with transformation details
110        """
111        input_path = Path(input_path)
112
113        if not input_path.exists():
114            raise FileNotFoundError(f"File not found: {input_path}")
115
116        if output_path is None:
117            output_path = input_path
118        else:
119            output_path = Path(output_path)
120
121        # Create backup if requested
122        if backup and not dry_run:
123            backup_path = input_path.with_suffix(input_path.suffix + ".backup")
124            import shutil
125
126            shutil.copy2(input_path, backup_path)
127
128        # Load and transform file based on extension
129        if input_path.suffix.lower() == ".csv":
130            if pd is None:
131                raise RuntimeError(
132                    "pandas is required to process CSV files. Install pandas."
133                )
134            df = pd.read_csv(input_path)
135            transform_df, audit_report = self.transform_dataframe(
136                df, input_path.stem, dry_run
137            )
138
139            if not dry_run:
140                transform_df.to_csv(output_path, index=False)
141
142            return audit_report
143
144        elif input_path.suffix.lower() == ".json":
145            if pd is None:
146                raise RuntimeError(
147                    "pandas is required to process JSON files. Install pandas."
148                )
149            df = pd.read_json(input_path)
150            transform_df, audit_report = self.transform_dataframe(
151                df, input_path.stem, dry_run
152            )
153
154            if not dry_run:
155                transform_df.to_json(output_path, orient="records", indent=2)
156
157            return audit_report
158
159        elif input_path.suffix.lower() == ".parquet":
160            if pd is None:
161                raise RuntimeError(
162                    "pandas is required to process Parquet files. Install pandas."
163                )
164            df = pd.read_parquet(input_path)
165            transform_df, audit_report = self.transform_dataframe(
166                df, input_path.stem, dry_run
167            )
168
169            if not dry_run:
170                transform_df.to_parquet(output_path, index=False)
171
172            return audit_report
173
174        elif input_path.suffix.lower() in [".txt", ".md"]:
175            with open(input_path, "r", encoding="utf-8") as f:
176                text = f.read()
177            transform_text, audit_report = self._transform.transform_text_with_report(
178                text,
179                dataset_name=input_path.stem,
180                job_name=f"transform_{input_path.stem}",
181                dry_run=dry_run,
182            )
183            if not dry_run:
184                with open(output_path, "w", encoding="utf-8") as f:
185                    f.write(transform_text)
186            return audit_report
187
188        else:
189            raise ValueError(f"Unsupported file format: {input_path.suffix}")
190
191    def transform_dictionary(
192        self, data: Dict[str, Any], dry_run: bool = False
193    ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
194        """
195        No PII from a dictionary.
196
197        Args:
198            data: Dictionary to transform
199            dry_run: If True, don't modify data but show what would be transform
200
201        Returns:
202            Tuple of (transform_dictionary, findings_list)
203        """
204        transform_dict, findings = self._transform.transform_dict(data, dry_run=dry_run)
205
206        # Convert findings to dictionaries for easier SDK usage
207        findings_dict = [
208            {
209                "type": f.type,
210                "original_value": f.value,
211                "transformed_value": f.transformed_value,
212                "confidence": f.confidence,
213                "key": f.column,
214                "action_taken": f.action_taken,
215                "evidence": f.evidence,
216            }
217            for f in findings
218        ]
219
220        return transform_dict, findings_dict
221
222    def preview_transformation(
223        self, data: Union[pd.DataFrame, str, Dict[str, Any]], max_samples: int = 10
224    ) -> List[Dict[str, Any]]:
225        """
226        Preview what would be transformed without actually modifying the data.
227
228        Args:
229            data: Data to preview transformation for
230            max_samples: Maximum number of samples to return
231
232        Returns:
233            List of transformation previews
234        """
235        if isinstance(data, pd.DataFrame):
236            _, audit_report = self.transform_dataframe(data, dry_run=True)
237            findings = audit_report.findings[:max_samples]
238
239            return [
240                {
241                    "type": f.type,
242                    "column": f.column,
243                    "row_index": f.row_index,
244                    "original_value": f.value,
245                    "transformed_value": f.transformed_value,
246                    "action": f.action_taken,
247                    "confidence": f.confidence,
248                }
249                for f in findings
250            ]
251
252        elif isinstance(data, str):
253            _, text_findings = self.transform_text(data, dry_run=True)
254
255            return text_findings[:max_samples]
256
257        elif isinstance(data, dict):
258            _, dict_findings = self.transform_dictionary(data, dry_run=True)
259
260            return dict_findings[:max_samples]
261
262        else:
263            raise ValueError(f"Unsupported data type: {type(data)}")
264
265    def list_transformers(self) -> List[Dict[str, Any]]:
266        """
267        List available transformers.
268
269        Returns:
270            List of transformer information
271        """
272        registry = self._transform.transform_registry
273        names = registry.list_transformers()
274        out: List[Dict[str, Any]] = []
275        for name in names:
276            t = registry.get_transformer(name)
277            if not t:
278                continue
279            info = t.get_info()
280            out.append(
281                {
282                    "name": name,
283                    "description": info.get("description", ""),
284                    "reversible": t.is_reversible(),
285                }
286            )
287        return out
288
289    def get_transformer_info(self, transformer_name: str) -> Dict[str, Any]:
290        """
291        Get information about a specific transformer.
292
293        Args:
294            transformer_name: Name of the transformer
295
296        Returns:
297            Dictionary with transformer information
298        """
299        transformer = self._transform.transform_registry.get_transformer(
300            transformer_name
301        )
302        if not transformer:
303            raise ValueError(f"Transformer not found: {transformer_name}")
304
305        return transformer.get_info()
306
307    def test_transformer(
308        self,
309        transformer_name: str,
310        test_data: str,
311        options: Optional[Dict[str, Any]] = None,
312    ) -> str:
313        """
314        Test a specific transformer against sample data.
315
316        Args:
317            transformer_name: Name of the transformer to test
318            test_data: Sample data to transform
319            options: Optional transformer options
320
321        Returns:
322            Transformed data
323        """
324        result = self._transform.transform_registry.transform(
325            test_data, "unknown", transformer_name, options or {}
326        )
327        if not result.success:
328            raise ValueError(result.error_message or "Transformation failed")
329        return result.transformed_value or test_data
330
331    def calculate_transformation_stats(
332        self, audit_report: AuditReport
333    ) -> Dict[str, Any]:
334        """
335        Calculate transformation statistics from an audit report.
336
337        Args:
338            audit_report: Audit report to analyze
339
340        Returns:
341            Dictionary with transformation statistics
342        """
343        if not audit_report.findings:
344            return {
345                "total_findings": 0,
346                "transform_count": 0,
347                "redaction_rate": 0.0,
348                "skipped_count": 0,
349                "skip_rate": 0.0,
350                "action_distribution": {},
351            }
352
353        total_findings = len(audit_report.findings)
354        transform_count = len(
355            [
356                f
357                for f in audit_report.findings
358                if f.action_taken and f.action_taken != "skip"
359            ]
360        )
361        skipped_count = len(
362            [
363                f
364                for f in audit_report.findings
365                if not f.action_taken or f.action_taken == "skip"
366            ]
367        )
368
369        # Action distribution
370        action_distribution: Dict[str, int] = {}
371        for finding in audit_report.findings:
372            action = finding.action_taken or "none"
373            action_distribution[action] = action_distribution.get(action, 0) + 1
374
375        return {
376            "total_findings": total_findings,
377            "transform_count": transform_count,
378            "redaction_rate": round((transform_count / total_findings) * 100, 1),
379            "skipped_count": skipped_count,
380            "skip_rate": round((skipped_count / total_findings) * 100, 1),
381            "action_distribution": action_distribution,
382            "coverage_score": round(audit_report.coverage_score * 100, 1),
383            "residual_risk": round(audit_report.residual_risk * 100, 1),
384        }
385
386    def __repr__(self) -> str:
387        """String representation of the SDK transform."""
388        return f"SDKTRANSFORM(transformers={len(self._transform.transform_registry.list_transformers())})"

SDK wrapper for the Transform class.

Provides a simplified interface for PII transformation operations.

SDKTransform(transform: Optional[Transform] = None)
28    def __init__(self, transform: Optional[Transform] = None):
29        """
30        Initialize the SDK transform.
31
32        Args:
33            transform: Core TRANSFORM instance
34        """
35        self._transform = transform or Transform(create_default_policy())

Initialize the SDK transform.

Args: transform: Core TRANSFORM instance

def transform_dataframe( self, df: pandas.DataFrame, dataset_name: Optional[str] = None, dry_run: bool = False) -> Tuple[pandas.DataFrame, AuditReport]:
37    def transform_dataframe(
38        self,
39        df: pd.DataFrame,
40        dataset_name: Optional[str] = None,
41        dry_run: bool = False,
42    ) -> Tuple[pd.DataFrame, AuditReport]:
43        """
44        No PII from a pandas DataFrame.
45
46        Args:
47            df: DataFrame to transform
48            dataset_name: Optional name for the dataset
49            dry_run: If True, don't modify data but show what would be transform
50
51        Returns:
52            Tuple of (transform_dataframe, audit_report)
53        """
54        return self._transform.transform_dataframe(
55            df, dataset_name or "dataframe", dry_run=dry_run
56        )

No PII from a pandas DataFrame.

Args: df: DataFrame to transform dataset_name: Optional name for the dataset dry_run: If True, don't modify data but show what would be transform

Returns: Tuple of (transform_dataframe, audit_report)

def transform_text( self, text: str, dry_run: bool = False) -> Tuple[str, List[Dict[str, Any]]]:
58    def transform_text(
59        self, text: str, dry_run: bool = False
60    ) -> Tuple[str, List[Dict[str, Any]]]:
61        """
62        No PII from text.
63
64        Args:
65            text: Text to transform
66            dry_run: If True, don't modify text but show what would be transform
67
68        Returns:
69            Tuple of (transform_text, findings_list)
70        """
71        transform_text, findings = self._transform.transform_text(text, dry_run=dry_run)
72
73        # Convert findings to dictionaries for easier SDK usage
74        findings_dict = [
75            {
76                "type": f.type,
77                "original_value": f.value,
78                "transformed_value": f.transformed_value,
79                "confidence": f.confidence,
80                "start_pos": f.span[0],
81                "end_pos": f.span[1],
82                "action_taken": f.action_taken,
83                "column": "text",
84                "row_index": 0,
85                "evidence": f.evidence,
86            }
87            for f in findings
88        ]
89
90        return transform_text, findings_dict

No PII from text.

Args: text: Text to transform dry_run: If True, don't modify text but show what would be transform

Returns: Tuple of (transform_text, findings_list)

def transform_file( self, input_path: Union[str, pathlib.Path], output_path: Union[str, pathlib.Path, NoneType] = None, dry_run: bool = False, backup: bool = True) -> AuditReport:
 92    def transform_file(
 93        self,
 94        input_path: Union[str, Path],
 95        output_path: Optional[Union[str, Path]] = None,
 96        dry_run: bool = False,
 97        backup: bool = True,
 98    ) -> AuditReport:
 99        """
100        No PII from a file.
101
102        Args:
103            input_path: Path to input file
104            output_path: Path to output file (defaults to input_path)
105            dry_run: If True, don't modify file but show what would be transform
106            backup: If True, create backup of original file
107
108        Returns:
109            AuditReport with transformation details
110        """
111        input_path = Path(input_path)
112
113        if not input_path.exists():
114            raise FileNotFoundError(f"File not found: {input_path}")
115
116        if output_path is None:
117            output_path = input_path
118        else:
119            output_path = Path(output_path)
120
121        # Create backup if requested
122        if backup and not dry_run:
123            backup_path = input_path.with_suffix(input_path.suffix + ".backup")
124            import shutil
125
126            shutil.copy2(input_path, backup_path)
127
128        # Load and transform file based on extension
129        if input_path.suffix.lower() == ".csv":
130            if pd is None:
131                raise RuntimeError(
132                    "pandas is required to process CSV files. Install pandas."
133                )
134            df = pd.read_csv(input_path)
135            transform_df, audit_report = self.transform_dataframe(
136                df, input_path.stem, dry_run
137            )
138
139            if not dry_run:
140                transform_df.to_csv(output_path, index=False)
141
142            return audit_report
143
144        elif input_path.suffix.lower() == ".json":
145            if pd is None:
146                raise RuntimeError(
147                    "pandas is required to process JSON files. Install pandas."
148                )
149            df = pd.read_json(input_path)
150            transform_df, audit_report = self.transform_dataframe(
151                df, input_path.stem, dry_run
152            )
153
154            if not dry_run:
155                transform_df.to_json(output_path, orient="records", indent=2)
156
157            return audit_report
158
159        elif input_path.suffix.lower() == ".parquet":
160            if pd is None:
161                raise RuntimeError(
162                    "pandas is required to process Parquet files. Install pandas."
163                )
164            df = pd.read_parquet(input_path)
165            transform_df, audit_report = self.transform_dataframe(
166                df, input_path.stem, dry_run
167            )
168
169            if not dry_run:
170                transform_df.to_parquet(output_path, index=False)
171
172            return audit_report
173
174        elif input_path.suffix.lower() in [".txt", ".md"]:
175            with open(input_path, "r", encoding="utf-8") as f:
176                text = f.read()
177            transform_text, audit_report = self._transform.transform_text_with_report(
178                text,
179                dataset_name=input_path.stem,
180                job_name=f"transform_{input_path.stem}",
181                dry_run=dry_run,
182            )
183            if not dry_run:
184                with open(output_path, "w", encoding="utf-8") as f:
185                    f.write(transform_text)
186            return audit_report
187
188        else:
189            raise ValueError(f"Unsupported file format: {input_path.suffix}")

No PII from a file.

Args: input_path: Path to input file output_path: Path to output file (defaults to input_path) dry_run: If True, don't modify file but show what would be transform backup: If True, create backup of original file

Returns: AuditReport with transformation details

def transform_dictionary( self, data: Dict[str, Any], dry_run: bool = False) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
191    def transform_dictionary(
192        self, data: Dict[str, Any], dry_run: bool = False
193    ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
194        """
195        No PII from a dictionary.
196
197        Args:
198            data: Dictionary to transform
199            dry_run: If True, don't modify data but show what would be transform
200
201        Returns:
202            Tuple of (transform_dictionary, findings_list)
203        """
204        transform_dict, findings = self._transform.transform_dict(data, dry_run=dry_run)
205
206        # Convert findings to dictionaries for easier SDK usage
207        findings_dict = [
208            {
209                "type": f.type,
210                "original_value": f.value,
211                "transformed_value": f.transformed_value,
212                "confidence": f.confidence,
213                "key": f.column,
214                "action_taken": f.action_taken,
215                "evidence": f.evidence,
216            }
217            for f in findings
218        ]
219
220        return transform_dict, findings_dict

No PII from a dictionary.

Args: data: Dictionary to transform dry_run: If True, don't modify data but show what would be transform

Returns: Tuple of (transform_dictionary, findings_list)

def preview_transformation( self, data: Union[pandas.DataFrame, str, Dict[str, Any]], max_samples: int = 10) -> List[Dict[str, Any]]:
222    def preview_transformation(
223        self, data: Union[pd.DataFrame, str, Dict[str, Any]], max_samples: int = 10
224    ) -> List[Dict[str, Any]]:
225        """
226        Preview what would be transformed without actually modifying the data.
227
228        Args:
229            data: Data to preview transformation for
230            max_samples: Maximum number of samples to return
231
232        Returns:
233            List of transformation previews
234        """
235        if isinstance(data, pd.DataFrame):
236            _, audit_report = self.transform_dataframe(data, dry_run=True)
237            findings = audit_report.findings[:max_samples]
238
239            return [
240                {
241                    "type": f.type,
242                    "column": f.column,
243                    "row_index": f.row_index,
244                    "original_value": f.value,
245                    "transformed_value": f.transformed_value,
246                    "action": f.action_taken,
247                    "confidence": f.confidence,
248                }
249                for f in findings
250            ]
251
252        elif isinstance(data, str):
253            _, text_findings = self.transform_text(data, dry_run=True)
254
255            return text_findings[:max_samples]
256
257        elif isinstance(data, dict):
258            _, dict_findings = self.transform_dictionary(data, dry_run=True)
259
260            return dict_findings[:max_samples]
261
262        else:
263            raise ValueError(f"Unsupported data type: {type(data)}")

Preview what would be transformed without actually modifying the data.

Args: data: Data to preview transformation for max_samples: Maximum number of samples to return

Returns: List of transformation previews

def list_transformers(self) -> List[Dict[str, Any]]:
265    def list_transformers(self) -> List[Dict[str, Any]]:
266        """
267        List available transformers.
268
269        Returns:
270            List of transformer information
271        """
272        registry = self._transform.transform_registry
273        names = registry.list_transformers()
274        out: List[Dict[str, Any]] = []
275        for name in names:
276            t = registry.get_transformer(name)
277            if not t:
278                continue
279            info = t.get_info()
280            out.append(
281                {
282                    "name": name,
283                    "description": info.get("description", ""),
284                    "reversible": t.is_reversible(),
285                }
286            )
287        return out

List available transformers.

Returns: List of transformer information

def get_transformer_info(self, transformer_name: str) -> Dict[str, Any]:
289    def get_transformer_info(self, transformer_name: str) -> Dict[str, Any]:
290        """
291        Get information about a specific transformer.
292
293        Args:
294            transformer_name: Name of the transformer
295
296        Returns:
297            Dictionary with transformer information
298        """
299        transformer = self._transform.transform_registry.get_transformer(
300            transformer_name
301        )
302        if not transformer:
303            raise ValueError(f"Transformer not found: {transformer_name}")
304
305        return transformer.get_info()

Get information about a specific transformer.

Args: transformer_name: Name of the transformer

Returns: Dictionary with transformer information

def test_transformer( self, transformer_name: str, test_data: str, options: Optional[Dict[str, Any]] = None) -> str:
307    def test_transformer(
308        self,
309        transformer_name: str,
310        test_data: str,
311        options: Optional[Dict[str, Any]] = None,
312    ) -> str:
313        """
314        Test a specific transformer against sample data.
315
316        Args:
317            transformer_name: Name of the transformer to test
318            test_data: Sample data to transform
319            options: Optional transformer options
320
321        Returns:
322            Transformed data
323        """
324        result = self._transform.transform_registry.transform(
325            test_data, "unknown", transformer_name, options or {}
326        )
327        if not result.success:
328            raise ValueError(result.error_message or "Transformation failed")
329        return result.transformed_value or test_data

Test a specific transformer against sample data.

Args: transformer_name: Name of the transformer to test test_data: Sample data to transform options: Optional transformer options

Returns: Transformed data

def calculate_transformation_stats(self, audit_report: AuditReport) -> Dict[str, Any]:
331    def calculate_transformation_stats(
332        self, audit_report: AuditReport
333    ) -> Dict[str, Any]:
334        """
335        Calculate transformation statistics from an audit report.
336
337        Args:
338            audit_report: Audit report to analyze
339
340        Returns:
341            Dictionary with transformation statistics
342        """
343        if not audit_report.findings:
344            return {
345                "total_findings": 0,
346                "transform_count": 0,
347                "redaction_rate": 0.0,
348                "skipped_count": 0,
349                "skip_rate": 0.0,
350                "action_distribution": {},
351            }
352
353        total_findings = len(audit_report.findings)
354        transform_count = len(
355            [
356                f
357                for f in audit_report.findings
358                if f.action_taken and f.action_taken != "skip"
359            ]
360        )
361        skipped_count = len(
362            [
363                f
364                for f in audit_report.findings
365                if not f.action_taken or f.action_taken == "skip"
366            ]
367        )
368
369        # Action distribution
370        action_distribution: Dict[str, int] = {}
371        for finding in audit_report.findings:
372            action = finding.action_taken or "none"
373            action_distribution[action] = action_distribution.get(action, 0) + 1
374
375        return {
376            "total_findings": total_findings,
377            "transform_count": transform_count,
378            "redaction_rate": round((transform_count / total_findings) * 100, 1),
379            "skipped_count": skipped_count,
380            "skip_rate": round((skipped_count / total_findings) * 100, 1),
381            "action_distribution": action_distribution,
382            "coverage_score": round(audit_report.coverage_score * 100, 1),
383            "residual_risk": round(audit_report.residual_risk * 100, 1),
384        }

Calculate transformation statistics from an audit report.

Args: audit_report: Audit report to analyze

Returns: Dictionary with transformation statistics

class SDKPolicy:
 14class SDKPolicy:
 15    """
 16    SDK wrapper for Policy management.
 17
 18    Provides a simplified interface for policy operations.
 19    """
 20
 21    def __init__(self, policy: Policy):
 22        """
 23        Initialize the SDK policy.
 24
 25        Args:
 26            policy: Core Policy instance
 27        """
 28        self._policy = policy
 29        self._validator = PolicyValidator()
 30
 31    @property
 32    def name(self) -> str:
 33        """Get policy name."""
 34        return self._policy.name
 35
 36    @property
 37    def version(self) -> str:
 38        """Get policy version."""
 39        return self._policy.version
 40
 41    @property
 42    def description(self) -> Optional[str]:
 43        """Get policy description."""
 44        return self._policy.description
 45
 46    @property
 47    def default_action(self) -> str:
 48        """Get default action."""
 49        return self._policy.default_action
 50
 51    def get_info(self) -> Dict[str, Any]:
 52        """
 53        Get comprehensive policy information.
 54
 55        Returns:
 56            Dictionary with policy information
 57        """
 58        return {
 59            "name": self._policy.name,
 60            "version": self._policy.version,
 61            "description": self._policy.description,
 62            "default_action": self._policy.default_action,
 63            "rules_count": len(self._policy.rules),
 64            "exceptions_count": len(self._policy.exceptions),
 65            "policy_hash": self._policy.policy_hash,
 66            "reporting_config": self._policy.reporting,
 67        }
 68
 69    def list_rules(self) -> List[Dict[str, Any]]:
 70        """List all rules with indices as ids."""
 71        return [
 72            {
 73                "id": idx,
 74                "match": rule.match,
 75                "columns": rule.columns,
 76                "action": rule.action,
 77                "options": rule.options,
 78                "override_confidence": rule.override_confidence,
 79            }
 80            for idx, rule in enumerate(self._policy.rules)
 81        ]
 82
 83    def get_rule(self, rule_id: int) -> Optional[Dict[str, Any]]:
 84        """Get a single rule by index id."""
 85        if 0 <= rule_id < len(self._policy.rules):
 86            rule = self._policy.rules[rule_id]
 87            return {
 88                "id": rule_id,
 89                "match": rule.match,
 90                "columns": rule.columns,
 91                "action": rule.action,
 92                "options": rule.options,
 93                "override_confidence": rule.override_confidence,
 94            }
 95        return None
 96
 97    def add_rule(self, rule_data: Dict[str, Any]) -> int:
 98        """Add a new rule (dict must match core Rule fields). Returns new rule id."""
 99        rule = Rule(**rule_data)
100        self._policy.rules.append(rule)
101        return len(self._policy.rules) - 1
102
103    def remove_rule(self, rule_id: int) -> bool:
104        """
105        Remove a rule from the policy.
106
107        Args:
108            rule_name: Name of the rule to remove
109
110        Returns:
111            True if rule was removed, False if not found
112        """
113        if 0 <= rule_id < len(self._policy.rules):
114            del self._policy.rules[rule_id]
115            return True
116        return False
117
118    def update_rule(self, rule_id: int, **kwargs) -> bool:
119        """
120        Update an existing rule.
121
122        Args:
123            rule_name: Name of the rule to update
124            **kwargs: Fields to update
125
126        Returns:
127            True if rule was updated, False if not found
128        """
129        if 0 <= rule_id < len(self._policy.rules):
130            rule = self._policy.rules[rule_id]
131            for key, value in kwargs.items():
132                if hasattr(rule, key):
133                    setattr(rule, key, value)
134            return True
135        return False
136
137    def list_exceptions(self) -> List[Dict[str, Any]]:
138        """
139        List all exceptions in the policy.
140
141        Returns:
142            List of exception information
143        """
144        return [
145            {
146                "dataset": exc.dataset,
147                "allow_types": exc.allow_types,
148                "conditions": exc.conditions,
149            }
150            for exc in self._policy.exceptions
151        ]
152
153    def add_exception(
154        self,
155        dataset: str,
156        allow_types: List[str],
157        conditions: Optional[Dict[str, Any]] = None,
158    ) -> None:
159        """Add a new exception to the policy."""
160        exception = PolicyException(
161            dataset=dataset, allow_types=allow_types, conditions=conditions or {}
162        )
163        self._policy.exceptions.append(exception)
164
165    def remove_exception(self, index: int) -> bool:
166        """
167        Remove an exception from the policy.
168
169        Args:
170            exception_name: Name of the exception to remove
171
172        Returns:
173            True if exception was removed, False if not found
174        """
175        if 0 <= index < len(self._policy.exceptions):
176            del self._policy.exceptions[index]
177            return True
178        return False
179
180    def set_default_action(self, action: str) -> None:
181        """
182        Set the default action for the policy.
183
184        Args:
185            action: Default action (redact, mask, hash, etc.)
186        """
187        self._policy.default_action = action
188
189    def update_reporting_config(self, **kwargs) -> None:
190        """
191        Update reporting configuration.
192
193        Args:
194            **kwargs: Reporting configuration fields to update
195        """
196        for key, value in kwargs.items():
197            if key in self._policy.reporting:
198                self._policy.reporting[key] = value
199
200    def validate(self) -> Dict[str, Any]:
201        """
202        Validate the current policy.
203
204        Returns:
205            Dictionary with validation results
206        """
207        policy_dict = self._policy.model_dump()
208        result = self._validator.validate(policy_dict)
209
210        return {
211            "is_valid": result.is_valid,
212            "errors": result.errors,
213            "warnings": result.warnings,
214        }
215
216    def save(self, file_path: Union[str, Path]) -> None:
217        """
218        Save the policy to a file.
219
220        Args:
221            file_path: Path to save the policy file
222        """
223        save_policy(self._policy, file_path)
224
225    def clone(self, new_name: Optional[str] = None) -> "SDKPolicy":
226        """
227        Create a copy of the current policy.
228
229        Args:
230            new_name: Optional new name for the cloned policy
231
232        Returns:
233            New SDKPolicy instance with copied policy
234        """
235        # Create a deep copy of the policy
236        policy_dict = self._policy.model_dump()
237
238        if new_name:
239            policy_dict["name"] = new_name
240
241        # Create new policy from dict
242        from ..policy.loader import load_policy_from_dict
243
244        new_policy = load_policy_from_dict(policy_dict)
245
246        return SDKPolicy(new_policy)
247
248    def get_applicable_rules(
249        self,
250        pii_type: Optional[str] = None,
251        column: Optional[str] = None,
252        dataset: Optional[str] = None,
253    ) -> List[Dict[str, Any]]:
254        """
255        Get rules that would apply to specific criteria.
256
257        Args:
258            pii_type: PII type to check
259            column: Column name to check
260            dataset: Dataset name to check (currently unused)
261
262        Returns:
263            List of applicable rules (preserving policy order)
264        """
265        applicable: List[Dict[str, Any]] = []
266
267        for idx, rule in enumerate(self._policy.rules):
268            match_ok = True
269            if pii_type is not None and rule.match is not None:
270                match_ok = rule.match == pii_type
271
272            column_ok = True
273            if column is not None and rule.columns is not None:
274                column_ok = column in rule.columns
275
276            # If both criteria provided and both are constrained, require both
277            if pii_type is not None and column is not None:
278                if rule.match is not None and rule.columns is not None:
279                    if not (match_ok and column_ok):
280                        continue
281                else:
282                    if rule.match is not None and not match_ok:
283                        continue
284                    if rule.columns is not None and not column_ok:
285                        continue
286            else:
287                # Only enforce the provided constraints
288                if pii_type is not None and rule.match is not None and not match_ok:
289                    continue
290                if column is not None and rule.columns is not None and not column_ok:
291                    continue
292
293            applicable.append(
294                {
295                    "id": idx,
296                    "match": rule.match,
297                    "columns": rule.columns,
298                    "action": rule.action,
299                    "options": rule.options,
300                    "override_confidence": rule.override_confidence,
301                }
302            )
303
304        return applicable
305
306    def test_rule_matching(
307        self, test_cases: List[Dict[str, Any]]
308    ) -> List[Dict[str, Any]]:
309        """
310        Test rule matching against test cases.
311
312        Args:
313            test_cases: List of test cases with 'type', 'column', 'dataset' keys
314
315        Returns:
316            List of test results
317        """
318        results = []
319
320        for test_case in test_cases:
321            applicable_rules = self.get_applicable_rules(
322                pii_type=test_case.get("type"),
323                column=test_case.get("column"),
324                dataset=test_case.get("dataset"),
325            )
326
327            results.append(
328                {
329                    "test_case": test_case,
330                    "matching_rules": applicable_rules,
331                    "action_taken": applicable_rules[0]["action"]
332                    if applicable_rules
333                    else self._policy.default_action,
334                    "rule_used": (
335                        f"rule_index:{applicable_rules[0]['id']}"
336                        if applicable_rules
337                        else "default"
338                    ),
339                }
340            )
341
342        return results
343
344    def get_statistics(self) -> Dict[str, Any]:
345        """
346        Get policy statistics.
347
348        Returns:
349            Dictionary with policy statistics
350        """
351        action_counts: Dict[str, int] = {}
352        type_counts: Dict[str, int] = {}
353        for rule in self._policy.rules:
354            action_counts[rule.action] = action_counts.get(rule.action, 0) + 1
355            if rule.match:
356                type_counts[rule.match] = type_counts.get(rule.match, 0) + 1
357        return {
358            "total_rules": len(self._policy.rules),
359            "total_exceptions": len(self._policy.exceptions),
360            "action_distribution": action_counts,
361            "type_distribution": type_counts,
362            "default_action": self._policy.default_action,
363        }
364
365    def __repr__(self) -> str:
366        """String representation of the SDK policy."""
367        return f"SDKPolicy(name='{self._policy.name}', rules={len(self._policy.rules)}, exceptions={len(self._policy.exceptions)})"

SDK wrapper for Policy management.

Provides a simplified interface for policy operations.

SDKPolicy(policy: Policy)
21    def __init__(self, policy: Policy):
22        """
23        Initialize the SDK policy.
24
25        Args:
26            policy: Core Policy instance
27        """
28        self._policy = policy
29        self._validator = PolicyValidator()

Initialize the SDK policy.

Args: policy: Core Policy instance

name: str
31    @property
32    def name(self) -> str:
33        """Get policy name."""
34        return self._policy.name

Get policy name.

version: str
36    @property
37    def version(self) -> str:
38        """Get policy version."""
39        return self._policy.version

Get policy version.

description: Optional[str]
41    @property
42    def description(self) -> Optional[str]:
43        """Get policy description."""
44        return self._policy.description

Get policy description.

default_action: str
46    @property
47    def default_action(self) -> str:
48        """Get default action."""
49        return self._policy.default_action

Get default action.

def get_info(self) -> Dict[str, Any]:
51    def get_info(self) -> Dict[str, Any]:
52        """
53        Get comprehensive policy information.
54
55        Returns:
56            Dictionary with policy information
57        """
58        return {
59            "name": self._policy.name,
60            "version": self._policy.version,
61            "description": self._policy.description,
62            "default_action": self._policy.default_action,
63            "rules_count": len(self._policy.rules),
64            "exceptions_count": len(self._policy.exceptions),
65            "policy_hash": self._policy.policy_hash,
66            "reporting_config": self._policy.reporting,
67        }

Get comprehensive policy information.

Returns: Dictionary with policy information

def list_rules(self) -> List[Dict[str, Any]]:
69    def list_rules(self) -> List[Dict[str, Any]]:
70        """List all rules with indices as ids."""
71        return [
72            {
73                "id": idx,
74                "match": rule.match,
75                "columns": rule.columns,
76                "action": rule.action,
77                "options": rule.options,
78                "override_confidence": rule.override_confidence,
79            }
80            for idx, rule in enumerate(self._policy.rules)
81        ]

List all rules with indices as ids.

def get_rule(self, rule_id: int) -> Optional[Dict[str, Any]]:
83    def get_rule(self, rule_id: int) -> Optional[Dict[str, Any]]:
84        """Get a single rule by index id."""
85        if 0 <= rule_id < len(self._policy.rules):
86            rule = self._policy.rules[rule_id]
87            return {
88                "id": rule_id,
89                "match": rule.match,
90                "columns": rule.columns,
91                "action": rule.action,
92                "options": rule.options,
93                "override_confidence": rule.override_confidence,
94            }
95        return None

Get a single rule by index id.

def add_rule(self, rule_data: Dict[str, Any]) -> int:
 97    def add_rule(self, rule_data: Dict[str, Any]) -> int:
 98        """Add a new rule (dict must match core Rule fields). Returns new rule id."""
 99        rule = Rule(**rule_data)
100        self._policy.rules.append(rule)
101        return len(self._policy.rules) - 1

Add a new rule (dict must match core Rule fields). Returns new rule id.

def remove_rule(self, rule_id: int) -> bool:
103    def remove_rule(self, rule_id: int) -> bool:
104        """
105        Remove a rule from the policy.
106
107        Args:
108            rule_name: Name of the rule to remove
109
110        Returns:
111            True if rule was removed, False if not found
112        """
113        if 0 <= rule_id < len(self._policy.rules):
114            del self._policy.rules[rule_id]
115            return True
116        return False

Remove a rule from the policy.

Args: rule_name: Name of the rule to remove

Returns: True if rule was removed, False if not found

def update_rule(self, rule_id: int, **kwargs) -> bool:
118    def update_rule(self, rule_id: int, **kwargs) -> bool:
119        """
120        Update an existing rule.
121
122        Args:
123            rule_name: Name of the rule to update
124            **kwargs: Fields to update
125
126        Returns:
127            True if rule was updated, False if not found
128        """
129        if 0 <= rule_id < len(self._policy.rules):
130            rule = self._policy.rules[rule_id]
131            for key, value in kwargs.items():
132                if hasattr(rule, key):
133                    setattr(rule, key, value)
134            return True
135        return False

Update an existing rule.

Args: rule_name: Name of the rule to update **kwargs: Fields to update

Returns: True if rule was updated, False if not found

def list_exceptions(self) -> List[Dict[str, Any]]:
137    def list_exceptions(self) -> List[Dict[str, Any]]:
138        """
139        List all exceptions in the policy.
140
141        Returns:
142            List of exception information
143        """
144        return [
145            {
146                "dataset": exc.dataset,
147                "allow_types": exc.allow_types,
148                "conditions": exc.conditions,
149            }
150            for exc in self._policy.exceptions
151        ]

List all exceptions in the policy.

Returns: List of exception information

def add_exception( self, dataset: str, allow_types: List[str], conditions: Optional[Dict[str, Any]] = None) -> None:
153    def add_exception(
154        self,
155        dataset: str,
156        allow_types: List[str],
157        conditions: Optional[Dict[str, Any]] = None,
158    ) -> None:
159        """Add a new exception to the policy."""
160        exception = PolicyException(
161            dataset=dataset, allow_types=allow_types, conditions=conditions or {}
162        )
163        self._policy.exceptions.append(exception)

Add a new exception to the policy.

def remove_exception(self, index: int) -> bool:
165    def remove_exception(self, index: int) -> bool:
166        """
167        Remove an exception from the policy.
168
169        Args:
170            exception_name: Name of the exception to remove
171
172        Returns:
173            True if exception was removed, False if not found
174        """
175        if 0 <= index < len(self._policy.exceptions):
176            del self._policy.exceptions[index]
177            return True
178        return False

Remove an exception from the policy.

Args: exception_name: Name of the exception to remove

Returns: True if exception was removed, False if not found

def set_default_action(self, action: str) -> None:
180    def set_default_action(self, action: str) -> None:
181        """
182        Set the default action for the policy.
183
184        Args:
185            action: Default action (redact, mask, hash, etc.)
186        """
187        self._policy.default_action = action

Set the default action for the policy.

Args: action: Default action (redact, mask, hash, etc.)

def update_reporting_config(self, **kwargs) -> None:
189    def update_reporting_config(self, **kwargs) -> None:
190        """
191        Update reporting configuration.
192
193        Args:
194            **kwargs: Reporting configuration fields to update
195        """
196        for key, value in kwargs.items():
197            if key in self._policy.reporting:
198                self._policy.reporting[key] = value

Update reporting configuration.

Args: **kwargs: Reporting configuration fields to update

def validate(self) -> Dict[str, Any]:
200    def validate(self) -> Dict[str, Any]:
201        """
202        Validate the current policy.
203
204        Returns:
205            Dictionary with validation results
206        """
207        policy_dict = self._policy.model_dump()
208        result = self._validator.validate(policy_dict)
209
210        return {
211            "is_valid": result.is_valid,
212            "errors": result.errors,
213            "warnings": result.warnings,
214        }

Validate the current policy.

Returns: Dictionary with validation results

def save(self, file_path: Union[str, pathlib.Path]) -> None:
216    def save(self, file_path: Union[str, Path]) -> None:
217        """
218        Save the policy to a file.
219
220        Args:
221            file_path: Path to save the policy file
222        """
223        save_policy(self._policy, file_path)

Save the policy to a file.

Args: file_path: Path to save the policy file

def clone(self, new_name: Optional[str] = None) -> SDKPolicy:
225    def clone(self, new_name: Optional[str] = None) -> "SDKPolicy":
226        """
227        Create a copy of the current policy.
228
229        Args:
230            new_name: Optional new name for the cloned policy
231
232        Returns:
233            New SDKPolicy instance with copied policy
234        """
235        # Create a deep copy of the policy
236        policy_dict = self._policy.model_dump()
237
238        if new_name:
239            policy_dict["name"] = new_name
240
241        # Create new policy from dict
242        from ..policy.loader import load_policy_from_dict
243
244        new_policy = load_policy_from_dict(policy_dict)
245
246        return SDKPolicy(new_policy)

Create a copy of the current policy.

Args: new_name: Optional new name for the cloned policy

Returns: New SDKPolicy instance with copied policy

def get_applicable_rules( self, pii_type: Optional[str] = None, column: Optional[str] = None, dataset: Optional[str] = None) -> List[Dict[str, Any]]:
248    def get_applicable_rules(
249        self,
250        pii_type: Optional[str] = None,
251        column: Optional[str] = None,
252        dataset: Optional[str] = None,
253    ) -> List[Dict[str, Any]]:
254        """
255        Get rules that would apply to specific criteria.
256
257        Args:
258            pii_type: PII type to check
259            column: Column name to check
260            dataset: Dataset name to check (currently unused)
261
262        Returns:
263            List of applicable rules (preserving policy order)
264        """
265        applicable: List[Dict[str, Any]] = []
266
267        for idx, rule in enumerate(self._policy.rules):
268            match_ok = True
269            if pii_type is not None and rule.match is not None:
270                match_ok = rule.match == pii_type
271
272            column_ok = True
273            if column is not None and rule.columns is not None:
274                column_ok = column in rule.columns
275
276            # If both criteria provided and both are constrained, require both
277            if pii_type is not None and column is not None:
278                if rule.match is not None and rule.columns is not None:
279                    if not (match_ok and column_ok):
280                        continue
281                else:
282                    if rule.match is not None and not match_ok:
283                        continue
284                    if rule.columns is not None and not column_ok:
285                        continue
286            else:
287                # Only enforce the provided constraints
288                if pii_type is not None and rule.match is not None and not match_ok:
289                    continue
290                if column is not None and rule.columns is not None and not column_ok:
291                    continue
292
293            applicable.append(
294                {
295                    "id": idx,
296                    "match": rule.match,
297                    "columns": rule.columns,
298                    "action": rule.action,
299                    "options": rule.options,
300                    "override_confidence": rule.override_confidence,
301                }
302            )
303
304        return applicable

Get rules that would apply to specific criteria.

Args: pii_type: PII type to check column: Column name to check dataset: Dataset name to check (currently unused)

Returns: List of applicable rules (preserving policy order)

def test_rule_matching(self, test_cases: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
306    def test_rule_matching(
307        self, test_cases: List[Dict[str, Any]]
308    ) -> List[Dict[str, Any]]:
309        """
310        Test rule matching against test cases.
311
312        Args:
313            test_cases: List of test cases with 'type', 'column', 'dataset' keys
314
315        Returns:
316            List of test results
317        """
318        results = []
319
320        for test_case in test_cases:
321            applicable_rules = self.get_applicable_rules(
322                pii_type=test_case.get("type"),
323                column=test_case.get("column"),
324                dataset=test_case.get("dataset"),
325            )
326
327            results.append(
328                {
329                    "test_case": test_case,
330                    "matching_rules": applicable_rules,
331                    "action_taken": applicable_rules[0]["action"]
332                    if applicable_rules
333                    else self._policy.default_action,
334                    "rule_used": (
335                        f"rule_index:{applicable_rules[0]['id']}"
336                        if applicable_rules
337                        else "default"
338                    ),
339                }
340            )
341
342        return results

Test rule matching against test cases.

Args: test_cases: List of test cases with 'type', 'column', 'dataset' keys

Returns: List of test results

def get_statistics(self) -> Dict[str, Any]:
344    def get_statistics(self) -> Dict[str, Any]:
345        """
346        Get policy statistics.
347
348        Returns:
349            Dictionary with policy statistics
350        """
351        action_counts: Dict[str, int] = {}
352        type_counts: Dict[str, int] = {}
353        for rule in self._policy.rules:
354            action_counts[rule.action] = action_counts.get(rule.action, 0) + 1
355            if rule.match:
356                type_counts[rule.match] = type_counts.get(rule.match, 0) + 1
357        return {
358            "total_rules": len(self._policy.rules),
359            "total_exceptions": len(self._policy.exceptions),
360            "action_distribution": action_counts,
361            "type_distribution": type_counts,
362            "default_action": self._policy.default_action,
363        }

Get policy statistics.

Returns: Dictionary with policy statistics

class HTMLReportGenerator(nopii.reporting.generators.BaseReportGenerator):
191class HTMLReportGenerator(BaseReportGenerator):
192    """Generates HTML audit reports."""
193
194    def generate(
195        self,
196        audit_report: AuditReport,
197        output_path: Optional[Path] = None,
198        template_name: str = "default",
199        include_samples: bool = False,
200        **kwargs,
201    ) -> str:
202        """
203        Generate HTML report.
204
205        Args:
206            audit_report: The audit report to generate from
207            output_path: Optional path to save the report
208            template_name: Name of the template to use
209            include_samples: Whether to include PII samples
210            **kwargs: Additional template context
211
212        Returns:
213            Generated HTML content
214        """
215        # Get template
216        template_content = get_template(template_name, "html")
217        template = Template(template_content)
218
219        # Prepare context
220        context = self._prepare_context(audit_report, include_samples, **kwargs)
221
222        # Render template
223        html_content = template.render(**context)
224
225        # Save if output path provided
226        if output_path:
227            output_path.parent.mkdir(parents=True, exist_ok=True)
228            with open(output_path, "w", encoding="utf-8") as f:
229                f.write(html_content)
230
231        return html_content

Generates HTML audit reports.

def generate( self, audit_report: AuditReport, output_path: Optional[pathlib.Path] = None, template_name: str = 'default', include_samples: bool = False, **kwargs) -> str:
194    def generate(
195        self,
196        audit_report: AuditReport,
197        output_path: Optional[Path] = None,
198        template_name: str = "default",
199        include_samples: bool = False,
200        **kwargs,
201    ) -> str:
202        """
203        Generate HTML report.
204
205        Args:
206            audit_report: The audit report to generate from
207            output_path: Optional path to save the report
208            template_name: Name of the template to use
209            include_samples: Whether to include PII samples
210            **kwargs: Additional template context
211
212        Returns:
213            Generated HTML content
214        """
215        # Get template
216        template_content = get_template(template_name, "html")
217        template = Template(template_content)
218
219        # Prepare context
220        context = self._prepare_context(audit_report, include_samples, **kwargs)
221
222        # Render template
223        html_content = template.render(**context)
224
225        # Save if output path provided
226        if output_path:
227            output_path.parent.mkdir(parents=True, exist_ok=True)
228            with open(output_path, "w", encoding="utf-8") as f:
229                f.write(html_content)
230
231        return html_content

Generate HTML report.

Args: audit_report: The audit report to generate from output_path: Optional path to save the report template_name: Name of the template to use include_samples: Whether to include PII samples **kwargs: Additional template context

Returns: Generated HTML content

class MarkdownReportGenerator(nopii.reporting.generators.BaseReportGenerator):
234class MarkdownReportGenerator(BaseReportGenerator):
235    """Generates Markdown audit reports."""
236
237    def generate(
238        self,
239        audit_report: AuditReport,
240        output_path: Optional[Path] = None,
241        template_name: str = "default",
242        include_samples: bool = False,
243        **kwargs,
244    ) -> str:
245        """
246        Generate Markdown report.
247
248        Args:
249            audit_report: The audit report to generate from
250            output_path: Optional path to save the report
251            template_name: Name of the template to use
252            include_samples: Whether to include PII samples
253            **kwargs: Additional template context
254
255        Returns:
256            Generated Markdown content
257        """
258        # Get template
259        template_content = get_template(template_name, "markdown")
260        template = Template(template_content)
261
262        # Prepare context
263        context = self._prepare_context(audit_report, include_samples, **kwargs)
264
265        # Render template
266        markdown_content = template.render(**context)
267
268        # Save if output path provided
269        if output_path:
270            output_path.parent.mkdir(parents=True, exist_ok=True)
271            with open(output_path, "w", encoding="utf-8") as f:
272                f.write(markdown_content)
273
274        return markdown_content

Generates Markdown audit reports.

def generate( self, audit_report: AuditReport, output_path: Optional[pathlib.Path] = None, template_name: str = 'default', include_samples: bool = False, **kwargs) -> str:
237    def generate(
238        self,
239        audit_report: AuditReport,
240        output_path: Optional[Path] = None,
241        template_name: str = "default",
242        include_samples: bool = False,
243        **kwargs,
244    ) -> str:
245        """
246        Generate Markdown report.
247
248        Args:
249            audit_report: The audit report to generate from
250            output_path: Optional path to save the report
251            template_name: Name of the template to use
252            include_samples: Whether to include PII samples
253            **kwargs: Additional template context
254
255        Returns:
256            Generated Markdown content
257        """
258        # Get template
259        template_content = get_template(template_name, "markdown")
260        template = Template(template_content)
261
262        # Prepare context
263        context = self._prepare_context(audit_report, include_samples, **kwargs)
264
265        # Render template
266        markdown_content = template.render(**context)
267
268        # Save if output path provided
269        if output_path:
270            output_path.parent.mkdir(parents=True, exist_ok=True)
271            with open(output_path, "w", encoding="utf-8") as f:
272                f.write(markdown_content)
273
274        return markdown_content

Generate Markdown report.

Args: audit_report: The audit report to generate from output_path: Optional path to save the report template_name: Name of the template to use include_samples: Whether to include PII samples **kwargs: Additional template context

Returns: Generated Markdown content

class JSONReportGenerator(nopii.reporting.generators.BaseReportGenerator):
277class JSONReportGenerator(BaseReportGenerator):
278    """Generates JSON audit reports."""
279
280    def generate(
281        self,
282        audit_report: AuditReport,
283        output_path: Optional[Path] = None,
284        include_samples: bool = False,
285        pretty: bool = True,
286        **kwargs,
287    ) -> str:
288        """
289        Generate JSON report.
290
291        Args:
292            audit_report: The audit report to generate from
293            output_path: Optional path to save the report
294            include_samples: Whether to include PII samples
295            pretty: Whether to format JSON with indentation
296            **kwargs: Additional data to include
297
298        Returns:
299            Generated JSON content
300        """
301        # Prepare context
302        context = self._prepare_context(audit_report, include_samples, **kwargs)
303
304        # Add raw audit report data (optionally sanitizing PII samples)
305        report_data = audit_report.model_dump()
306        if not include_samples:
307            # Remove raw values from nested scan_result findings if present
308            try:
309                for f in report_data.get("scan_result", {}).get("findings", []):
310                    # 'value' and possibly 'evidence' may contain samples
311                    f.pop("value", None)
312            except Exception as e:
313                logging.warning("Failed to remove raw values from findings: %s", e)
314
315        # Combine context and report data
316        json_data = {
317            "report_metadata": {
318                "generated_at": datetime.now().isoformat(),
319                "generator_version": __version__,
320                "include_samples": include_samples,
321            },
322            "summary": {
323                "job_name": context["job_name"],
324                "timestamp": context["timestamp"],
325                "coverage_score": context["coverage_score"],
326                "residual_risk": context["residual_risk"],
327                "total_findings": context["total_findings"],
328                "processing_time": context["processing_time"],
329                "risk_level": context["risk_level"],
330            },
331            "metrics": {
332                "coverage": {
333                    "transform_rate": context["transform_rate"],
334                    "transformation_success_rate": context[
335                        "transformation_success_rate"
336                    ],
337                    "policy_compliance": context["policy_compliance"],
338                },
339                "risk": {
340                    "overall_risk": context["overall_risk"],
341                    "high_confidence_risk": context["high_confidence_risk"],
342                    "sensitive_type_risk": context["sensitive_type_risk"],
343                    "risk_score": context["risk_score"],
344                },
345                "data_quality": {
346                    "preservation_rate": context["preservation_rate"],
347                    "null_introduction_rate": context["null_introduction_rate"],
348                    "dtype_preservation_rate": context["dtype_preservation_rate"],
349                    "format_preservation_rate": context["format_preservation_rate"],
350                },
351            },
352            "statistics": {
353                "pii_type_counts": context["pii_type_counts"],
354                "unique_types": context["unique_types"],
355                "affected_columns": context["affected_columns"],
356            },
357            "audit_report": report_data,
358        }
359
360        # Add any additional data
361        json_data.update(kwargs)
362
363        # Generate JSON
364        if pretty:
365            json_content = json.dumps(
366                json_data, indent=2, default=str, ensure_ascii=False
367            )
368        else:
369            json_content = json.dumps(json_data, default=str, ensure_ascii=False)
370
371        # Save if output path provided
372        if output_path:
373            output_path.parent.mkdir(parents=True, exist_ok=True)
374            with open(output_path, "w", encoding="utf-8") as f:
375                f.write(json_content)
376
377        return json_content

Generates JSON audit reports.

def generate( self, audit_report: AuditReport, output_path: Optional[pathlib.Path] = None, include_samples: bool = False, pretty: bool = True, **kwargs) -> str:
280    def generate(
281        self,
282        audit_report: AuditReport,
283        output_path: Optional[Path] = None,
284        include_samples: bool = False,
285        pretty: bool = True,
286        **kwargs,
287    ) -> str:
288        """
289        Generate JSON report.
290
291        Args:
292            audit_report: The audit report to generate from
293            output_path: Optional path to save the report
294            include_samples: Whether to include PII samples
295            pretty: Whether to format JSON with indentation
296            **kwargs: Additional data to include
297
298        Returns:
299            Generated JSON content
300        """
301        # Prepare context
302        context = self._prepare_context(audit_report, include_samples, **kwargs)
303
304        # Add raw audit report data (optionally sanitizing PII samples)
305        report_data = audit_report.model_dump()
306        if not include_samples:
307            # Remove raw values from nested scan_result findings if present
308            try:
309                for f in report_data.get("scan_result", {}).get("findings", []):
310                    # 'value' and possibly 'evidence' may contain samples
311                    f.pop("value", None)
312            except Exception as e:
313                logging.warning("Failed to remove raw values from findings: %s", e)
314
315        # Combine context and report data
316        json_data = {
317            "report_metadata": {
318                "generated_at": datetime.now().isoformat(),
319                "generator_version": __version__,
320                "include_samples": include_samples,
321            },
322            "summary": {
323                "job_name": context["job_name"],
324                "timestamp": context["timestamp"],
325                "coverage_score": context["coverage_score"],
326                "residual_risk": context["residual_risk"],
327                "total_findings": context["total_findings"],
328                "processing_time": context["processing_time"],
329                "risk_level": context["risk_level"],
330            },
331            "metrics": {
332                "coverage": {
333                    "transform_rate": context["transform_rate"],
334                    "transformation_success_rate": context[
335                        "transformation_success_rate"
336                    ],
337                    "policy_compliance": context["policy_compliance"],
338                },
339                "risk": {
340                    "overall_risk": context["overall_risk"],
341                    "high_confidence_risk": context["high_confidence_risk"],
342                    "sensitive_type_risk": context["sensitive_type_risk"],
343                    "risk_score": context["risk_score"],
344                },
345                "data_quality": {
346                    "preservation_rate": context["preservation_rate"],
347                    "null_introduction_rate": context["null_introduction_rate"],
348                    "dtype_preservation_rate": context["dtype_preservation_rate"],
349                    "format_preservation_rate": context["format_preservation_rate"],
350                },
351            },
352            "statistics": {
353                "pii_type_counts": context["pii_type_counts"],
354                "unique_types": context["unique_types"],
355                "affected_columns": context["affected_columns"],
356            },
357            "audit_report": report_data,
358        }
359
360        # Add any additional data
361        json_data.update(kwargs)
362
363        # Generate JSON
364        if pretty:
365            json_content = json.dumps(
366                json_data, indent=2, default=str, ensure_ascii=False
367            )
368        else:
369            json_content = json.dumps(json_data, default=str, ensure_ascii=False)
370
371        # Save if output path provided
372        if output_path:
373            output_path.parent.mkdir(parents=True, exist_ok=True)
374            with open(output_path, "w", encoding="utf-8") as f:
375                f.write(json_content)
376
377        return json_content

Generate JSON report.

Args: audit_report: The audit report to generate from output_path: Optional path to save the report include_samples: Whether to include PII samples pretty: Whether to format JSON with indentation **kwargs: Additional data to include

Returns: Generated JSON content

class CoverageCalculator:
 11class CoverageCalculator:
 12    """
 13    Calculates coverage scores and metrics for PII detection and transformation.
 14    """
 15
 16    def __init__(self):
 17        pass
 18
 19    def calculate_detection_coverage(
 20        self, scan_result: ScanResult, total_cells: Optional[int] = None
 21    ) -> Dict[str, float]:
 22        """
 23        Calculate detection coverage metrics.
 24
 25        Args:
 26            scan_result: Results from PII scanning
 27            total_cells: Total number of cells scanned (optional)
 28
 29        Returns:
 30            Dictionary with coverage metrics
 31        """
 32        if not scan_result.findings:
 33            return {
 34                "overall_coverage": 0.0,
 35                "column_coverage": 0.0,
 36                "type_coverage": 0.0,
 37                "confidence_weighted_coverage": 0.0,
 38            }
 39
 40        # Calculate overall coverage
 41        pii_cells = len(scan_result.findings)
 42        overall_coverage = (pii_cells / total_cells) if total_cells else 0.0
 43
 44        # Calculate column coverage
 45        columns_with_pii = len(set(f.column for f in scan_result.findings if f.column))
 46        # Prefer explicit metadata if available
 47        meta = getattr(scan_result, "scan_metadata", {}) or {}
 48        total_columns = (
 49            meta.get("total_columns")
 50            or len(meta.get("columns", []))
 51            or scan_result.total_columns
 52        )
 53        column_coverage = (columns_with_pii / total_columns) if total_columns else 0.0
 54
 55        # Calculate type coverage (diversity of PII types found)
 56        unique_types = len(set(f.type for f in scan_result.findings))
 57        # Assume we're looking for common PII types
 58        expected_types = 8  # email, phone, ssn, credit_card, etc.
 59        type_coverage = min(unique_types / expected_types, 1.0)
 60
 61        # Calculate confidence-weighted coverage
 62        total_confidence = sum(f.confidence for f in scan_result.findings)
 63        confidence_weighted_coverage = total_confidence / len(scan_result.findings)
 64
 65        return {
 66            "overall_coverage": overall_coverage,
 67            "column_coverage": column_coverage,
 68            "type_coverage": type_coverage,
 69            "confidence_weighted_coverage": confidence_weighted_coverage,
 70        }
 71
 72    def calculate_transform_coverage(
 73        self, audit_report: AuditReport
 74    ) -> Dict[str, float]:
 75        """
 76        Calculate transform coverage metrics.
 77
 78        Args:
 79            audit_report: Audit report from transform process
 80
 81        Returns:
 82            Dictionary with transform coverage metrics
 83        """
 84        if not audit_report.findings:
 85            return {
 86                "transform_rate": 0.0,
 87                "transformation_success_rate": 0.0,
 88                "policy_compliance_rate": 0.0,
 89            }
 90
 91        # Calculate transform rate
 92        transform_findings = [f for f in audit_report.findings if f.action_taken]
 93        transform_rate = len(transform_findings) / len(audit_report.findings)
 94
 95        # Calculate transformation success rate
 96        successful_transformations = [
 97            f for f in transform_findings if f.action_taken and f.action_taken != "skip"
 98        ]
 99        transformation_success_rate = (
100            len(successful_transformations) / len(transform_findings)
101            if transform_findings
102            else 0.0
103        )
104
105        # Calculate policy compliance rate (high confidence findings processed)
106        high_confidence_findings = [
107            f for f in audit_report.findings if f.confidence >= 0.8
108        ]
109        processed_high_confidence = [
110            f
111            for f in high_confidence_findings
112            if f.action_taken and f.action_taken != "skip"
113        ]
114        policy_compliance_rate = (
115            len(processed_high_confidence) / len(high_confidence_findings)
116            if high_confidence_findings
117            else 1.0
118        )
119
120        return {
121            "transform_rate": transform_rate,
122            "transformation_success_rate": transformation_success_rate,
123            "policy_compliance_rate": policy_compliance_rate,
124        }
125
126    def calculate_residual_risk(self, audit_report: AuditReport) -> Dict[str, float]:
127        """
128        Calculate residual risk metrics after transform.
129
130        Args:
131            audit_report: Audit report from transform process
132
133        Returns:
134            Dictionary with residual risk metrics
135        """
136        if not audit_report.findings:
137            return {
138                "overall_risk": 0.0,
139                "high_confidence_risk": 0.0,
140                "sensitive_type_risk": 0.0,
141                "risk_score": 0.0,
142            }
143
144        # Identify unprocessed findings
145        unprocessed_findings = [
146            f
147            for f in audit_report.findings
148            if not f.action_taken or f.action_taken == "skip"
149        ]
150
151        if not unprocessed_findings:
152            return {
153                "overall_risk": 0.0,
154                "high_confidence_risk": 0.0,
155                "sensitive_type_risk": 0.0,
156                "risk_score": 0.0,
157            }
158
159        # Calculate overall residual risk
160        overall_risk = len(unprocessed_findings) / len(audit_report.findings)
161
162        # Calculate high confidence residual risk
163        high_confidence_unprocessed = [
164            f for f in unprocessed_findings if f.confidence >= 0.8
165        ]
166        high_confidence_total = [
167            f for f in audit_report.findings if f.confidence >= 0.8
168        ]
169        high_confidence_risk = (
170            len(high_confidence_unprocessed) / len(high_confidence_total)
171            if high_confidence_total
172            else 0.0
173        )
174
175        # Calculate sensitive type risk
176        sensitive_types = {"ssn", "credit_card", "passport", "drivers_license"}
177        sensitive_unprocessed = [
178            f for f in unprocessed_findings if f.type.lower() in sensitive_types
179        ]
180        sensitive_total = [
181            f for f in audit_report.findings if f.type.lower() in sensitive_types
182        ]
183        sensitive_type_risk = (
184            len(sensitive_unprocessed) / len(sensitive_total)
185            if sensitive_total
186            else 0.0
187        )
188
189        # Calculate composite risk score
190        risk_score = (
191            overall_risk * 0.3 + high_confidence_risk * 0.4 + sensitive_type_risk * 0.3
192        )
193
194        return {
195            "overall_risk": overall_risk,
196            "high_confidence_risk": high_confidence_risk,
197            "sensitive_type_risk": sensitive_type_risk,
198            "risk_score": risk_score,
199        }
200
201    def calculate_data_quality_metrics(
202        self, original_df, transform_df
203    ) -> Dict[str, float]:
204        """
205        Calculate data quality metrics after transform.
206
207        Args:
208            original_df: Original dataframe
209            transform_df: TRANSFORM dataframe
210
211        Returns:
212            Dictionary with data quality metrics
213        """
214        if original_df.shape != transform_df.shape:
215            raise ValueError("DataFrames must have the same shape")
216
217        total_cells = original_df.size
218
219        # Calculate preservation metrics
220        unchanged_cells = (original_df == transform_df).sum().sum()
221        preservation_rate = unchanged_cells / total_cells
222
223        # Calculate null introduction rate
224        original_nulls = original_df.isnull().sum().sum()
225        transform_nulls = transform_df.isnull().sum().sum()
226        null_introduction_rate = (transform_nulls - original_nulls) / total_cells
227
228        # Calculate data type preservation
229        original_dtypes = set(original_df.dtypes.astype(str))
230        transform_dtypes = set(transform_df.dtypes.astype(str))
231        dtype_preservation_rate = len(original_dtypes & transform_dtypes) / len(
232            original_dtypes
233        )
234
235        # Calculate format preservation (for string columns)
236        format_preservation_scores = []
237        for col in original_df.select_dtypes(include=["object"]).columns:
238            if col in transform_df.columns:
239                orig_lengths = original_df[col].astype(str).str.len()
240                transform_lengths = transform_df[col].astype(str).str.len()
241                # Check if lengths are similar (within 20% difference)
242                length_similarity = (
243                    (abs(orig_lengths - transform_lengths) / orig_lengths.clip(lower=1))
244                    <= 0.2
245                ).mean()
246                format_preservation_scores.append(length_similarity)
247
248        format_preservation_rate = (
249            sum(format_preservation_scores) / len(format_preservation_scores)
250            if format_preservation_scores
251            else 1.0
252        )
253
254        return {
255            "preservation_rate": preservation_rate,
256            "null_introduction_rate": null_introduction_rate,
257            "dtype_preservation_rate": dtype_preservation_rate,
258            "format_preservation_rate": format_preservation_rate,
259        }

Calculates coverage scores and metrics for PII detection and transformation.

def calculate_detection_coverage( self, scan_result: ScanResult, total_cells: Optional[int] = None) -> Dict[str, float]:
19    def calculate_detection_coverage(
20        self, scan_result: ScanResult, total_cells: Optional[int] = None
21    ) -> Dict[str, float]:
22        """
23        Calculate detection coverage metrics.
24
25        Args:
26            scan_result: Results from PII scanning
27            total_cells: Total number of cells scanned (optional)
28
29        Returns:
30            Dictionary with coverage metrics
31        """
32        if not scan_result.findings:
33            return {
34                "overall_coverage": 0.0,
35                "column_coverage": 0.0,
36                "type_coverage": 0.0,
37                "confidence_weighted_coverage": 0.0,
38            }
39
40        # Calculate overall coverage
41        pii_cells = len(scan_result.findings)
42        overall_coverage = (pii_cells / total_cells) if total_cells else 0.0
43
44        # Calculate column coverage
45        columns_with_pii = len(set(f.column for f in scan_result.findings if f.column))
46        # Prefer explicit metadata if available
47        meta = getattr(scan_result, "scan_metadata", {}) or {}
48        total_columns = (
49            meta.get("total_columns")
50            or len(meta.get("columns", []))
51            or scan_result.total_columns
52        )
53        column_coverage = (columns_with_pii / total_columns) if total_columns else 0.0
54
55        # Calculate type coverage (diversity of PII types found)
56        unique_types = len(set(f.type for f in scan_result.findings))
57        # Assume we're looking for common PII types
58        expected_types = 8  # email, phone, ssn, credit_card, etc.
59        type_coverage = min(unique_types / expected_types, 1.0)
60
61        # Calculate confidence-weighted coverage
62        total_confidence = sum(f.confidence for f in scan_result.findings)
63        confidence_weighted_coverage = total_confidence / len(scan_result.findings)
64
65        return {
66            "overall_coverage": overall_coverage,
67            "column_coverage": column_coverage,
68            "type_coverage": type_coverage,
69            "confidence_weighted_coverage": confidence_weighted_coverage,
70        }

Calculate detection coverage metrics.

Args: scan_result: Results from PII scanning total_cells: Total number of cells scanned (optional)

Returns: Dictionary with coverage metrics

def calculate_transform_coverage(self, audit_report: AuditReport) -> Dict[str, float]:
 72    def calculate_transform_coverage(
 73        self, audit_report: AuditReport
 74    ) -> Dict[str, float]:
 75        """
 76        Calculate transform coverage metrics.
 77
 78        Args:
 79            audit_report: Audit report from transform process
 80
 81        Returns:
 82            Dictionary with transform coverage metrics
 83        """
 84        if not audit_report.findings:
 85            return {
 86                "transform_rate": 0.0,
 87                "transformation_success_rate": 0.0,
 88                "policy_compliance_rate": 0.0,
 89            }
 90
 91        # Calculate transform rate
 92        transform_findings = [f for f in audit_report.findings if f.action_taken]
 93        transform_rate = len(transform_findings) / len(audit_report.findings)
 94
 95        # Calculate transformation success rate
 96        successful_transformations = [
 97            f for f in transform_findings if f.action_taken and f.action_taken != "skip"
 98        ]
 99        transformation_success_rate = (
100            len(successful_transformations) / len(transform_findings)
101            if transform_findings
102            else 0.0
103        )
104
105        # Calculate policy compliance rate (high confidence findings processed)
106        high_confidence_findings = [
107            f for f in audit_report.findings if f.confidence >= 0.8
108        ]
109        processed_high_confidence = [
110            f
111            for f in high_confidence_findings
112            if f.action_taken and f.action_taken != "skip"
113        ]
114        policy_compliance_rate = (
115            len(processed_high_confidence) / len(high_confidence_findings)
116            if high_confidence_findings
117            else 1.0
118        )
119
120        return {
121            "transform_rate": transform_rate,
122            "transformation_success_rate": transformation_success_rate,
123            "policy_compliance_rate": policy_compliance_rate,
124        }

Calculate transform coverage metrics.

Args: audit_report: Audit report from transform process

Returns: Dictionary with transform coverage metrics

def calculate_residual_risk(self, audit_report: AuditReport) -> Dict[str, float]:
126    def calculate_residual_risk(self, audit_report: AuditReport) -> Dict[str, float]:
127        """
128        Calculate residual risk metrics after transform.
129
130        Args:
131            audit_report: Audit report from transform process
132
133        Returns:
134            Dictionary with residual risk metrics
135        """
136        if not audit_report.findings:
137            return {
138                "overall_risk": 0.0,
139                "high_confidence_risk": 0.0,
140                "sensitive_type_risk": 0.0,
141                "risk_score": 0.0,
142            }
143
144        # Identify unprocessed findings
145        unprocessed_findings = [
146            f
147            for f in audit_report.findings
148            if not f.action_taken or f.action_taken == "skip"
149        ]
150
151        if not unprocessed_findings:
152            return {
153                "overall_risk": 0.0,
154                "high_confidence_risk": 0.0,
155                "sensitive_type_risk": 0.0,
156                "risk_score": 0.0,
157            }
158
159        # Calculate overall residual risk
160        overall_risk = len(unprocessed_findings) / len(audit_report.findings)
161
162        # Calculate high confidence residual risk
163        high_confidence_unprocessed = [
164            f for f in unprocessed_findings if f.confidence >= 0.8
165        ]
166        high_confidence_total = [
167            f for f in audit_report.findings if f.confidence >= 0.8
168        ]
169        high_confidence_risk = (
170            len(high_confidence_unprocessed) / len(high_confidence_total)
171            if high_confidence_total
172            else 0.0
173        )
174
175        # Calculate sensitive type risk
176        sensitive_types = {"ssn", "credit_card", "passport", "drivers_license"}
177        sensitive_unprocessed = [
178            f for f in unprocessed_findings if f.type.lower() in sensitive_types
179        ]
180        sensitive_total = [
181            f for f in audit_report.findings if f.type.lower() in sensitive_types
182        ]
183        sensitive_type_risk = (
184            len(sensitive_unprocessed) / len(sensitive_total)
185            if sensitive_total
186            else 0.0
187        )
188
189        # Calculate composite risk score
190        risk_score = (
191            overall_risk * 0.3 + high_confidence_risk * 0.4 + sensitive_type_risk * 0.3
192        )
193
194        return {
195            "overall_risk": overall_risk,
196            "high_confidence_risk": high_confidence_risk,
197            "sensitive_type_risk": sensitive_type_risk,
198            "risk_score": risk_score,
199        }

Calculate residual risk metrics after transform.

Args: audit_report: Audit report from transform process

Returns: Dictionary with residual risk metrics

def calculate_data_quality_metrics(self, original_df, transform_df) -> Dict[str, float]:
201    def calculate_data_quality_metrics(
202        self, original_df, transform_df
203    ) -> Dict[str, float]:
204        """
205        Calculate data quality metrics after transform.
206
207        Args:
208            original_df: Original dataframe
209            transform_df: TRANSFORM dataframe
210
211        Returns:
212            Dictionary with data quality metrics
213        """
214        if original_df.shape != transform_df.shape:
215            raise ValueError("DataFrames must have the same shape")
216
217        total_cells = original_df.size
218
219        # Calculate preservation metrics
220        unchanged_cells = (original_df == transform_df).sum().sum()
221        preservation_rate = unchanged_cells / total_cells
222
223        # Calculate null introduction rate
224        original_nulls = original_df.isnull().sum().sum()
225        transform_nulls = transform_df.isnull().sum().sum()
226        null_introduction_rate = (transform_nulls - original_nulls) / total_cells
227
228        # Calculate data type preservation
229        original_dtypes = set(original_df.dtypes.astype(str))
230        transform_dtypes = set(transform_df.dtypes.astype(str))
231        dtype_preservation_rate = len(original_dtypes & transform_dtypes) / len(
232            original_dtypes
233        )
234
235        # Calculate format preservation (for string columns)
236        format_preservation_scores = []
237        for col in original_df.select_dtypes(include=["object"]).columns:
238            if col in transform_df.columns:
239                orig_lengths = original_df[col].astype(str).str.len()
240                transform_lengths = transform_df[col].astype(str).str.len()
241                # Check if lengths are similar (within 20% difference)
242                length_similarity = (
243                    (abs(orig_lengths - transform_lengths) / orig_lengths.clip(lower=1))
244                    <= 0.2
245                ).mean()
246                format_preservation_scores.append(length_similarity)
247
248        format_preservation_rate = (
249            sum(format_preservation_scores) / len(format_preservation_scores)
250            if format_preservation_scores
251            else 1.0
252        )
253
254        return {
255            "preservation_rate": preservation_rate,
256            "null_introduction_rate": null_introduction_rate,
257            "dtype_preservation_rate": dtype_preservation_rate,
258            "format_preservation_rate": format_preservation_rate,
259        }

Calculate data quality metrics after transform.

Args: original_df: Original dataframe transform_df: TRANSFORM dataframe

Returns: Dictionary with data quality metrics

__version__ = '0.1.4'
__author__ = 'ay-mich'