Per CONVENTIONS.md Part 2 ("Never bare except") and CONVENTIONS.md
Part 7 (pre-commit hooks: ruff), blind `except Exception` is now a
lint failure. Pre-existing sites are marked `# noqa: BLE001` for
later manual review; new code must use specific exception types.
Changes:
- pyproject.toml: added "BLE" to ruff lint select. BLE001 is now enforced
- 103 of 166 `except Exception` sites were auto-converted to specific
types based on context (httpx, json, OSError, subprocess, etc.)
- 62 remaining sites marked with `# noqa: BLE001` for later review
(mostly generic try/except wrappers that legitimately need broad catch
for graceful degradation: e.g. compliance LLM fallback must catch
any error to preserve the regex result)
- 1 manual fix: reverted compliance.py LLM fallback to broad except
with explicit "must catch all errors" comment + noqa
- 2 files (commerce_sync.py, crm_sync.py) needed `import httpx` added
so the auto-converted exception references would resolve
- 5 source files (agency, monitor, pipelines, auth_connector,
llm_providers/registry) renamed "name" -> "<scope>_name" in
extra={...} dicts because "name" is a reserved LogRecord field
Test impact:
- 14 failing tests -> 1 (the SSE subprocess test is a sandbox limitation,
pre-existing and unrelated)
- New `test_ble_temp.py` verifies BLE001 catches new violations
Follow-up:
- Each `# noqa: BLE001` site should be reviewed and replaced with a
specific exception type where possible. The most common legitimate
broad-catch case is the LLM fallback path; everything else probably
can be narrowed.
215 lines
8.2 KiB
Python
215 lines
8.2 KiB
Python
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
"""Pry — Real GDPR compliance: data subject access requests, data portability, real audit log."""
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import zipfile
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from paths import PRY_DATA_DIR
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GDPR_DIR = PRY_DATA_DIR / "gdpr_real"
|
|
GDPR_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
DATA_RESIDENCES = [
|
|
"quality/", "reviews/", "intel/", "costing/", "freshness/", "structure/", "seo/",
|
|
"monitors/", "vault/", "accounts/", "reports/", "training/", "pipelines/",
|
|
"agency/", "compliance/", "caching/", "stealth_scripts/", "jobs/",
|
|
]
|
|
|
|
|
|
class GDPRService:
|
|
"""Real GDPR compliance: data subject access, deletion, portability, audit."""
|
|
|
|
def __init__(self, db: Any = None) -> None:
|
|
self._db = db
|
|
self._audit_log_path = GDPR_DIR / "audit.log"
|
|
self._deletion_log_path = GDPR_DIR / "deletions.log"
|
|
|
|
def audit(self, action: str, subject_id: str = "", details: dict | None = None) -> None:
|
|
"""Write an immutable audit log entry."""
|
|
entry: dict[str, Any] = {
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"action": action,
|
|
"subject_id": subject_id,
|
|
"operator": "system",
|
|
"details": details or {},
|
|
}
|
|
try:
|
|
with open(self._audit_log_path, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
except OSError:
|
|
pass
|
|
logger.info("gdpr_audit", extra=entry)
|
|
|
|
def right_to_access(self, subject_id: str) -> dict[str, Any]:
|
|
"""GDPR Art. 15: Right of access by the data subject.
|
|
Find ALL data Pry holds about this person."""
|
|
self.audit("right_to_access", subject_id)
|
|
data_found: dict[str, Any] = {
|
|
"subject_id": subject_id,
|
|
"data_categories": [],
|
|
"records": {},
|
|
"total_records": 0,
|
|
}
|
|
pry_dir = PRY_DATA_DIR
|
|
subject_lower = subject_id.lower()
|
|
for subdir in DATA_RESIDENCES:
|
|
subdir_path = pry_dir / subdir
|
|
if not subdir_path.exists():
|
|
continue
|
|
for f in subdir_path.rglob("*"):
|
|
if not f.is_file() or f.suffix not in (".json", ".jsonl", ".txt"):
|
|
continue
|
|
try:
|
|
content = f.read_text()
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
if subject_id in content or subject_lower in content.lower():
|
|
data_found["data_categories"].append(str(subdir))
|
|
data_found["records"].setdefault(str(subdir), []).append({
|
|
"file": str(f.relative_to(pry_dir)),
|
|
"size": f.stat().st_size,
|
|
"modified": datetime.fromtimestamp(
|
|
f.stat().st_mtime, UTC
|
|
).isoformat(),
|
|
})
|
|
data_found["total_records"] += 1
|
|
return data_found
|
|
|
|
def right_to_erasure(self, subject_id: str, verify: bool = True) -> dict[str, Any]:
|
|
"""GDPR Art. 17: Right to erasure ('right to be forgotten').
|
|
Find and DELETE all data about this person."""
|
|
self.audit("right_to_erasure_initiated", subject_id)
|
|
access_data = self.right_to_access(subject_id)
|
|
if access_data["total_records"] == 0:
|
|
return {"success": True, "deleted": 0, "message": "No data found for subject"}
|
|
|
|
if verify:
|
|
return {
|
|
"requires_verification": True,
|
|
"would_delete": access_data,
|
|
"confirmation_required": (
|
|
f"POST /v1/gdpr/erasure/{subject_id} with confirm=true to proceed"
|
|
),
|
|
}
|
|
|
|
deleted = 0
|
|
home = Path(os.path.expanduser("~"))
|
|
for files in access_data["records"].values():
|
|
for file_info in files:
|
|
file_path = home / ".pry" / file_info["file"]
|
|
try:
|
|
if file_path.is_file():
|
|
file_path.unlink()
|
|
deleted += 1
|
|
elif file_path.is_dir():
|
|
shutil.rmtree(file_path)
|
|
deleted += 1
|
|
except OSError:
|
|
pass
|
|
|
|
if self._db is not None:
|
|
try:
|
|
from db import ApiKey, QualityCheckRecord, UsageRecord
|
|
|
|
with self._db.session() as s:
|
|
s.query(UsageRecord).filter(
|
|
UsageRecord.metadata_json.like(f"%{subject_id}%")
|
|
).delete()
|
|
s.query(QualityCheckRecord).filter(
|
|
QualityCheckRecord.url.like(f"%{subject_id}%")
|
|
).delete()
|
|
s.query(ApiKey).filter(
|
|
ApiKey.name.like(f"%{subject_id}%")
|
|
).delete()
|
|
except OSError:
|
|
pass
|
|
|
|
deletion_record = {
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"subject_id": subject_id,
|
|
"records_deleted": deleted,
|
|
}
|
|
try:
|
|
with open(self._deletion_log_path, "a") as f:
|
|
f.write(json.dumps(deletion_record) + "\n")
|
|
except OSError:
|
|
pass
|
|
self.audit("right_to_erasure_completed", subject_id, {"records_deleted": deleted})
|
|
return {"success": True, "deleted": deleted}
|
|
|
|
def data_portability_export(self, subject_id: str) -> dict[str, Any]:
|
|
"""GDPR Art. 20: Right to data portability.
|
|
Export all data about this person in a machine-readable format (JSON)."""
|
|
self.audit("data_portability_export", subject_id)
|
|
access_data = self.right_to_access(subject_id)
|
|
if access_data["total_records"] == 0:
|
|
return {"success": False, "error": "No data found"}
|
|
|
|
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
export_dir = GDPR_DIR / f"exports/{subject_id}_{timestamp}"
|
|
export_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
home = Path(os.path.expanduser("~"))
|
|
for files in access_data["records"].values():
|
|
for file_info in files:
|
|
src = home / ".pry" / file_info["file"]
|
|
if not src.exists():
|
|
continue
|
|
dest = export_dir / file_info["file"]
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
with contextlib.suppress(OSError, UnicodeDecodeError):
|
|
dest.write_text(src.read_text())
|
|
|
|
index = {
|
|
"exported_at": datetime.now(UTC).isoformat(),
|
|
"subject_id": subject_id,
|
|
"format": "JSON (machine-readable)",
|
|
"files": [f["file"] for cat in access_data["records"].values() for f in cat],
|
|
"instructions": "This is your personal data export under GDPR Art. 20.",
|
|
}
|
|
(export_dir / "INDEX.json").write_text(json.dumps(index, indent=2))
|
|
|
|
zip_path = export_dir.with_suffix(".zip")
|
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
for f in export_dir.rglob("*"):
|
|
if f.is_file():
|
|
zf.write(f, f.relative_to(export_dir))
|
|
self.audit(
|
|
"data_portability_export_completed",
|
|
subject_id,
|
|
{"export_path": str(zip_path)},
|
|
)
|
|
return {
|
|
"success": True,
|
|
"export_path": str(zip_path),
|
|
"files_count": access_data["total_records"],
|
|
}
|
|
|
|
def get_audit_log(
|
|
self, days_back: int = 30, subject_id: str = ""
|
|
) -> list[dict[str, Any]]:
|
|
"""Get audit log entries."""
|
|
entries: list[dict[str, Any]] = []
|
|
if not self._audit_log_path.exists():
|
|
return entries
|
|
for line in self._audit_log_path.read_text().splitlines():
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if subject_id and entry.get("subject_id") != subject_id:
|
|
continue
|
|
entries.append(entry)
|
|
return entries
|