From fabbd1fc690fd6785d7e661c0dc0b42d4877e9cd Mon Sep 17 00:00:00 2001 From: aidankhogg Date: Fri, 19 Sep 2025 15:01:33 +0100 Subject: [PATCH] Refine detector protocol context signature --- src/redactable/detectors/base.py | 73 +++++++++++++----------- src/redactable/detectors/credit_card.py | 9 ++- src/redactable/detectors/email.py | 11 +++- src/redactable/detectors/entropy.py | 19 ++++-- src/redactable/detectors/iban.py | 9 ++- src/redactable/detectors/nhs.py | 9 ++- src/redactable/detectors/phone.py | 10 +++- src/redactable/detectors/run.py | 7 ++- src/redactable/detectors/schema_hints.py | 8 ++- src/redactable/detectors/ssn.py | 10 +++- 10 files changed, 117 insertions(+), 48 deletions(-) diff --git a/src/redactable/detectors/base.py b/src/redactable/detectors/base.py index 146845c..fddc82b 100644 --- a/src/redactable/detectors/base.py +++ b/src/redactable/detectors/base.py @@ -1,53 +1,70 @@ -""" -Core types and helpers for detectors. +"""Core types and helpers for detectors. -Contents: -- Finding: dataclass representing a detected entity. -- Detector: Protocol interface that all detectors must implement. -- Shared helper functions: digits_only, luhn_ok, guess_card_brand. -""" +This module defines the public API surface that detectors consume: -from dataclasses import dataclass -from typing import Iterable, Optional, Protocol, Tuple, Dict, Any -import re - -# -------------------------------------------------------------------- -# Shared type aliases -Span = Tuple[int, int] -Extras = Dict[str, Any] +* :class:`Match` – lightweight results used by the built-in registry. +* :class:`Finding` – richer results used by the legacy registry layer. +* :class:`Detector` – the protocol detectors must implement. +* Helper utilities shared by multiple detectors. +""" +from __future__ import annotations +import re from dataclasses import dataclass -from typing import Iterable, Protocol, TypedDict, Optional, Dict, Any, List +from typing import Any, Iterable, Optional, Protocol @dataclass(slots=True) class Match: + """A lightweight finding produced by detectors registered in this module.""" + label: str # e.g. "EMAIL", "CREDIT_CARD" start: int # byte/char index in the input text end: int value: str # matched text (pre-transform) confidence: float = 1.0 # 0..1 - meta: dict[str, Any] = None + meta: dict[str, Any] | None = None + class Detector(Protocol): + """Protocol that built-in detectors adhere to. + + Detectors expose a :pydata:`name`, a collection of :pydata:`labels`, and a + :py:meth:`detect` method that yields :class:`Match` instances. The optional + ``context`` keyword argument allows callers to pass detector-specific + configuration without breaking the common interface. + """ + name: str labels: tuple[str, ...] - def detect(self, text: str, *, context: Optional[dict[str, Any]] = None) -> Iterable[Match]: ... + + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Match]: + ... + # Simple registry -_REGISTRY: Dict[str, Detector] = {} -_LABEL_TO_DETECTORS: Dict[str, List[str]] = {} +_REGISTRY: dict[str, Detector] = {} +_LABEL_TO_DETECTORS: dict[str, list[str]] = {} + def register(detector: Detector) -> None: _REGISTRY[detector.name] = detector for label in detector.labels: _LABEL_TO_DETECTORS.setdefault(label, []).append(detector.name) + def get(name: str) -> Detector: return _REGISTRY[name] + def detectors_for(label: str) -> list[Detector]: - return [ _REGISTRY[n] for n in _LABEL_TO_DETECTORS.get(label, []) ] + return [_REGISTRY[n] for n in _LABEL_TO_DETECTORS.get(label, [])] + def all_detectors() -> list[Detector]: return list(_REGISTRY.values()) @@ -67,10 +84,10 @@ class Finding: """ kind: str value: str - span: Span + span: tuple[int, int] confidence: float normalized: Optional[str] = None - extras: Extras | None = None + extras: dict[str, Any] | None = None def __post_init__(self) -> None: if not (0.0 <= self.confidence <= 1.0): @@ -81,16 +98,6 @@ def __post_init__(self) -> None: def __str__(self) -> str: return f"" - -class Detector(Protocol): - """ - Protocol that all detectors must follow. - Each detector must expose a `name` and a `detect` method. - """ - name: str - - def detect(self, text: str) -> Iterable[Finding]: ... - # -------------------------------------------------------------------- # Shared helpers diff --git a/src/redactable/detectors/credit_card.py b/src/redactable/detectors/credit_card.py index 1319375..84a70d6 100644 --- a/src/redactable/detectors/credit_card.py +++ b/src/redactable/detectors/credit_card.py @@ -1,4 +1,6 @@ import re +from typing import Any, Iterable, Optional + from .base import Match, register from .utils import luhn_check @@ -20,7 +22,12 @@ class CreditCardDetector: name = "credit_card" labels = ("CREDIT_CARD",) - def detect(self, text: str, *, context=None): + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Match]: for m in _PAN.finditer(text): raw = m.group(1) digits = ''.join(ch for ch in raw if ch.isdigit()) diff --git a/src/redactable/detectors/email.py b/src/redactable/detectors/email.py index 665ce49..4bab042 100644 --- a/src/redactable/detectors/email.py +++ b/src/redactable/detectors/email.py @@ -1,5 +1,7 @@ import re -from .base import Match, Detector, register +from typing import Any, Iterable, Optional + +from .base import Match, register _EMAIL = re.compile( r'(? Iterable[Match]: for m in _EMAIL.finditer(text): yield Match(label="EMAIL", start=m.start(1), end=m.end(1), value=m.group(1), confidence=0.95) diff --git a/src/redactable/detectors/entropy.py b/src/redactable/detectors/entropy.py index 5a25d49..83615a7 100644 --- a/src/redactable/detectors/entropy.py +++ b/src/redactable/detectors/entropy.py @@ -12,9 +12,10 @@ """ import re -from .base import Match, register, Finding, Detector +from typing import Any, Iterable, Optional + +from .base import Match, register, Finding from .utils import shannon_entropy, looks_like_secret -from typing import Iterable # -------------------------------------------------------------------- # Regex pattern: matches candidate secrets BASELIKE_PATTERN = re.compile( @@ -45,7 +46,12 @@ def __init__(self, entropy_threshold: float = 3.5, min_len: int = 24) -> None: self.entropy_threshold = entropy_threshold self.min_len = min_len - def detect(self, text: str) -> Iterable[Finding]: + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Finding]: """ Scan text for high-entropy sequences. Yields Finding objects for each match. @@ -76,7 +82,12 @@ class EntropyDetector: def __init__(self, *, threshold: float = 3.5): self.threshold = threshold - def detect(self, text: str, *, context=None): + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Match]: threshold = (context or {}).get("entropy_threshold", self.threshold) for m in _TOKEN.finditer(text): token = m.group(1) diff --git a/src/redactable/detectors/iban.py b/src/redactable/detectors/iban.py index 720dbd6..8678078 100644 --- a/src/redactable/detectors/iban.py +++ b/src/redactable/detectors/iban.py @@ -1,4 +1,6 @@ import re +from typing import Any, Iterable, Optional + from .base import Match, register from .utils import iban_check @@ -8,7 +10,12 @@ class IBANDetector: name = "iban" labels = ("IBAN",) - def detect(self, text: str, *, context=None): + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Match]: for m in _IBAN.finditer(text): token = m.group(1).upper() if iban_check(token): diff --git a/src/redactable/detectors/nhs.py b/src/redactable/detectors/nhs.py index 9b504ed..845f5fb 100644 --- a/src/redactable/detectors/nhs.py +++ b/src/redactable/detectors/nhs.py @@ -1,4 +1,6 @@ import re +from typing import Any, Iterable, Optional + from .base import Match, register from .utils import nhs_check @@ -9,7 +11,12 @@ class NHSDetector: name = "nhs" labels = ("NHS_NUMBER",) - def detect(self, text: str, *, context=None): + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Match]: for m in _NHS.finditer(text): raw = m.group(1) digits = ''.join(ch for ch in raw if ch.isdigit()) diff --git a/src/redactable/detectors/phone.py b/src/redactable/detectors/phone.py index 19bb18f..66e5ef0 100644 --- a/src/redactable/detectors/phone.py +++ b/src/redactable/detectors/phone.py @@ -1,5 +1,8 @@ from __future__ import annotations + import re +from typing import Any, Iterable, Optional + from .base import Match, register # E.164 (+441234567890), simple UK patterns (07..., 01/02... with spaces) @@ -10,7 +13,12 @@ class PhoneDetector: name = "phone" labels = ("PHONE",) - def detect(self, text: str, *, context=None): + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Match]: for m in _E164.finditer(text): yield Match("PHONE", m.start(1), m.end(1), m.group(1), 0.9, {"format": "E164"}) for m in _UK.finditer(text): diff --git a/src/redactable/detectors/run.py b/src/redactable/detectors/run.py index 1d53ae8..698a0f7 100644 --- a/src/redactable/detectors/run.py +++ b/src/redactable/detectors/run.py @@ -1,15 +1,16 @@ -from typing import Optional +from typing import Any, Optional + from .base import Match, all_detectors # Force registration even if package __init__ is bypassed # (pytest imports run_all directly in tests) from . import email, credit_card, iban, nhs, ssn, phone, entropy, schema_hints # noqa: F401 -def run_all(text: str, *, context: Optional[dict] = None) -> list[Match]: +def run_all(text: str, *, context: Optional[dict[str, Any]] = None) -> list[Match]: matches: list[Match] = [] for det in all_detectors(): for m in det.detect(text, context=context): if m is not None: matches.append(m) matches.sort(key=lambda m: (m.start, m.end)) - return matches \ No newline at end of file + return matches diff --git a/src/redactable/detectors/schema_hints.py b/src/redactable/detectors/schema_hints.py index 1c040c4..c1fae88 100644 --- a/src/redactable/detectors/schema_hints.py +++ b/src/redactable/detectors/schema_hints.py @@ -2,6 +2,7 @@ import re from collections.abc import Iterable, Mapping +from typing import Any, Optional from .base import Match, register @@ -27,7 +28,12 @@ class SchemaHintDetector: labels = tuple(set(_HINTS.values())) confidence = 0.6 - def detect(self, text: str, *, context=None): + def detect( + self, + text: str, + *, + context: Optional[dict[str, Any]] = None, + ) -> Iterable[Match]: schema = (context or {}).get("schema") if not schema: return [] diff --git a/src/redactable/detectors/ssn.py b/src/redactable/detectors/ssn.py index be0fdde..85369d0 100644 --- a/src/redactable/detectors/ssn.py +++ b/src/redactable/detectors/ssn.py @@ -1,5 +1,8 @@ from __future__ import annotations + import re +from typing import Any, Iterable, Optional + from .base import Match, register _SSN = re.compile(r'(? Iterable[Match]: for m in _SSN.finditer(text): raw = m.group(1) if _valid_ssn(raw):