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!")
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
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.
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.
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.
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.
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
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
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
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.
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.
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.
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
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.
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.
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.
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.
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
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
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
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.
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.
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
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.
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
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)
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)
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)
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.
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
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
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
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.
19 def __init__(self): 20 """Initialize the detector registry.""" 21 self._detectors: Dict[str, BaseDetector] = {} 22 self._load_default_detectors()
Initialize the detector registry.
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
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
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
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
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
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
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
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
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
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
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.
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.
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)
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
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
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
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
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
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
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
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
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)
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
91 @property 92 def current_policy(self) -> Policy: 93 """Get the current policy.""" 94 return self._policy
Get the current policy.
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
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
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")
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
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)
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"
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
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
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
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
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
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
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
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
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.
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
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
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
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
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
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.
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
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
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
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
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.
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
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)
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)
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
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)
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
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
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
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
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
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.
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
36 @property 37 def version(self) -> str: 38 """Get policy version.""" 39 return self._policy.version
Get policy version.
41 @property 42 def description(self) -> Optional[str]: 43 """Get policy description.""" 44 return self._policy.description
Get policy description.
46 @property 47 def default_action(self) -> str: 48 """Get default action.""" 49 return self._policy.default_action
Get default action.
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
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.
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.
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.
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
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
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
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.
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
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.)
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
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
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
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
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)
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
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
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.
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
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.
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
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.
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
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.
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
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
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
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