classifinder 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ """ClassiFinder Python SDK — scan and redact secrets from text."""
2
+
3
+ from ._client import ClassiFinder
4
+ from ._async_client import AsyncClassiFinder
5
+ from ._exceptions import (
6
+ ClassiFinderError,
7
+ AuthenticationError,
8
+ RateLimitError,
9
+ InvalidRequestError,
10
+ ForbiddenError,
11
+ ServerError,
12
+ APIConnectionError,
13
+ SecretsDetectedError,
14
+ )
15
+ from ._models import (
16
+ ScanResult,
17
+ RedactResult,
18
+ TypesResult,
19
+ HealthResult,
20
+ FeedbackResult,
21
+ Finding,
22
+ RedactFinding,
23
+ Span,
24
+ SeveritySummary,
25
+ TypeInfo,
26
+ )
27
+
28
+ __all__ = [
29
+ "ClassiFinder",
30
+ "AsyncClassiFinder",
31
+ "ClassiFinderError",
32
+ "AuthenticationError",
33
+ "RateLimitError",
34
+ "InvalidRequestError",
35
+ "ForbiddenError",
36
+ "ServerError",
37
+ "APIConnectionError",
38
+ "SecretsDetectedError",
39
+ "ScanResult",
40
+ "RedactResult",
41
+ "TypesResult",
42
+ "HealthResult",
43
+ "FeedbackResult",
44
+ "Finding",
45
+ "RedactFinding",
46
+ "Span",
47
+ "SeveritySummary",
48
+ "TypeInfo",
49
+ ]
@@ -0,0 +1,139 @@
1
+ """Asynchronous ClassiFinder client."""
2
+
3
+ from typing import List, Optional
4
+
5
+ import httpx
6
+
7
+ from ._base import (
8
+ DEFAULT_BASE_URL,
9
+ DEFAULT_MAX_RETRIES,
10
+ DEFAULT_TIMEOUT,
11
+ async_sleep_for_retry,
12
+ build_headers,
13
+ is_retryable,
14
+ raise_for_status,
15
+ resolve_api_key,
16
+ )
17
+ from ._exceptions import APIConnectionError
18
+ from ._models import (
19
+ FeedbackResult,
20
+ HealthResult,
21
+ RedactResult,
22
+ ScanResult,
23
+ TypesResult,
24
+ )
25
+
26
+
27
+ class AsyncClassiFinder:
28
+ """Asynchronous client for the ClassiFinder API."""
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: Optional[str] = None,
33
+ base_url: str = DEFAULT_BASE_URL,
34
+ max_retries: int = DEFAULT_MAX_RETRIES,
35
+ timeout: float = DEFAULT_TIMEOUT,
36
+ ) -> None:
37
+ self._api_key = resolve_api_key(api_key)
38
+ self._base_url = base_url.rstrip("/")
39
+ self._max_retries = max_retries
40
+ self._client = httpx.AsyncClient(
41
+ headers=build_headers(self._api_key),
42
+ timeout=timeout,
43
+ )
44
+
45
+ async def close(self) -> None:
46
+ """Close the underlying HTTP connection pool."""
47
+ await self._client.aclose()
48
+
49
+ async def __aenter__(self) -> "AsyncClassiFinder":
50
+ return self
51
+
52
+ async def __aexit__(self, *args) -> None:
53
+ await self.close()
54
+
55
+ async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
56
+ """Make an HTTP request with retry logic."""
57
+ url = f"{self._base_url}{path}"
58
+ last_exc: Optional[Exception] = None
59
+
60
+ for attempt in range(self._max_retries + 1):
61
+ try:
62
+ response = await self._client.request(method, url, **kwargs)
63
+ raise_for_status(response)
64
+ return response
65
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as exc:
66
+ api_exc = APIConnectionError(str(exc))
67
+ last_exc = api_exc
68
+ if attempt >= self._max_retries:
69
+ raise api_exc from exc
70
+ await async_sleep_for_retry(attempt, api_exc)
71
+ except Exception as exc:
72
+ last_exc = exc
73
+ if not is_retryable(exc) or attempt >= self._max_retries:
74
+ raise
75
+ await async_sleep_for_retry(attempt, exc)
76
+
77
+ raise last_exc # pragma: no cover
78
+
79
+ async def scan(
80
+ self,
81
+ text: str,
82
+ types: Optional[List[str]] = None,
83
+ min_confidence: float = 0.5,
84
+ include_context: bool = True,
85
+ ) -> ScanResult:
86
+ """Scan text for secrets."""
87
+ body = {
88
+ "text": text,
89
+ "types": types or ["all"],
90
+ "min_confidence": min_confidence,
91
+ "include_context": include_context,
92
+ }
93
+ response = await self._request("POST", "/v1/scan", json=body)
94
+ return ScanResult.model_validate(response.json())
95
+
96
+ async def redact(
97
+ self,
98
+ text: str,
99
+ types: Optional[List[str]] = None,
100
+ min_confidence: float = 0.5,
101
+ redaction_style: str = "label",
102
+ ) -> RedactResult:
103
+ """Scan and redact secrets from text."""
104
+ body = {
105
+ "text": text,
106
+ "types": types or ["all"],
107
+ "min_confidence": min_confidence,
108
+ "redaction_style": redaction_style,
109
+ }
110
+ response = await self._request("POST", "/v1/redact", json=body)
111
+ return RedactResult.model_validate(response.json())
112
+
113
+ async def get_types(self) -> TypesResult:
114
+ """List all detectable secret types."""
115
+ response = await self._request("GET", "/v1/types")
116
+ return TypesResult.model_validate(response.json())
117
+
118
+ async def health(self) -> HealthResult:
119
+ """Check API health."""
120
+ response = await self._request("GET", "/v1/health")
121
+ return HealthResult.model_validate(response.json())
122
+
123
+ async def feedback(
124
+ self,
125
+ request_id: str,
126
+ finding_id: str,
127
+ feedback_type: str,
128
+ comment: Optional[str] = None,
129
+ ) -> FeedbackResult:
130
+ """Report a false positive or false negative."""
131
+ body = {
132
+ "request_id": request_id,
133
+ "finding_id": finding_id,
134
+ "feedback_type": feedback_type,
135
+ }
136
+ if comment is not None:
137
+ body["comment"] = comment
138
+ response = await self._request("POST", "/v1/feedback", json=body)
139
+ return FeedbackResult.model_validate(response.json())
classifinder/_base.py ADDED
@@ -0,0 +1,104 @@
1
+ """Shared logic for sync and async clients: error mapping, retry, request building."""
2
+
3
+ import os
4
+ import time
5
+ from typing import Any, Dict, Optional
6
+
7
+ import httpx
8
+
9
+ from ._exceptions import (
10
+ AuthenticationError,
11
+ RateLimitError,
12
+ InvalidRequestError,
13
+ ForbiddenError,
14
+ ServerError,
15
+ APIConnectionError,
16
+ ClassiFinderError,
17
+ )
18
+
19
+ DEFAULT_BASE_URL = "https://api.classifinder.tech"
20
+ DEFAULT_TIMEOUT = 30.0
21
+ DEFAULT_MAX_RETRIES = 2
22
+
23
+ _RETRYABLE_STATUS_CODES = {429, 500}
24
+
25
+
26
+ def resolve_api_key(api_key: Optional[str]) -> str:
27
+ """Resolve API key from argument or CLASSIFINDER_API_KEY env var."""
28
+ key = api_key or os.environ.get("CLASSIFINDER_API_KEY")
29
+ if not key:
30
+ raise AuthenticationError(
31
+ "No API key provided. Pass api_key= or set the CLASSIFINDER_API_KEY environment variable."
32
+ )
33
+ return key
34
+
35
+
36
+ def build_headers(api_key: str) -> Dict[str, str]:
37
+ """Build default request headers."""
38
+ return {
39
+ "X-API-Key": api_key,
40
+ "Content-Type": "application/json",
41
+ }
42
+
43
+
44
+ def raise_for_status(response: httpx.Response) -> None:
45
+ """Raise the appropriate ClassiFinderError for non-2xx responses."""
46
+ if response.status_code < 400:
47
+ return
48
+
49
+ try:
50
+ body = response.json()
51
+ error = body.get("error", {})
52
+ message = error.get("message", response.text)
53
+ code = error.get("code", "")
54
+ retry_after = error.get("retry_after")
55
+ except Exception:
56
+ message = response.text or f"HTTP {response.status_code}"
57
+ code = ""
58
+ retry_after = None
59
+
60
+ status = response.status_code
61
+
62
+ if status == 401:
63
+ raise AuthenticationError(message)
64
+ elif status == 400:
65
+ raise InvalidRequestError(message, code=code)
66
+ elif status == 403:
67
+ raise ForbiddenError(message, code=code)
68
+ elif status == 429:
69
+ raise RateLimitError(message, retry_after=retry_after or 0)
70
+ elif status >= 500:
71
+ raise ServerError(message)
72
+ else:
73
+ raise ClassiFinderError(message, status_code=status)
74
+
75
+
76
+ def is_retryable(exc: Exception) -> bool:
77
+ """Check if an exception is retryable."""
78
+ if isinstance(exc, RateLimitError):
79
+ return True
80
+ if isinstance(exc, ServerError):
81
+ return True
82
+ if isinstance(exc, APIConnectionError):
83
+ return True
84
+ return False
85
+
86
+
87
+ def get_retry_delay(attempt: int, exc: Exception) -> float:
88
+ """Calculate delay before next retry attempt."""
89
+ if isinstance(exc, RateLimitError) and exc.retry_after > 0:
90
+ return float(exc.retry_after)
91
+ return float(2**attempt)
92
+
93
+
94
+ def sleep_for_retry(attempt: int, exc: Exception) -> None:
95
+ """Sleep before a sync retry."""
96
+ delay = get_retry_delay(attempt, exc)
97
+ time.sleep(delay)
98
+
99
+
100
+ async def async_sleep_for_retry(attempt: int, exc: Exception) -> None:
101
+ """Sleep before an async retry."""
102
+ import asyncio
103
+ delay = get_retry_delay(attempt, exc)
104
+ await asyncio.sleep(delay)
@@ -0,0 +1,139 @@
1
+ """Synchronous ClassiFinder client."""
2
+
3
+ from typing import List, Optional
4
+
5
+ import httpx
6
+
7
+ from ._base import (
8
+ DEFAULT_BASE_URL,
9
+ DEFAULT_MAX_RETRIES,
10
+ DEFAULT_TIMEOUT,
11
+ build_headers,
12
+ is_retryable,
13
+ raise_for_status,
14
+ resolve_api_key,
15
+ sleep_for_retry,
16
+ )
17
+ from ._exceptions import APIConnectionError
18
+ from ._models import (
19
+ FeedbackResult,
20
+ HealthResult,
21
+ RedactResult,
22
+ ScanResult,
23
+ TypesResult,
24
+ )
25
+
26
+
27
+ class ClassiFinder:
28
+ """Synchronous client for the ClassiFinder API."""
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: Optional[str] = None,
33
+ base_url: str = DEFAULT_BASE_URL,
34
+ max_retries: int = DEFAULT_MAX_RETRIES,
35
+ timeout: float = DEFAULT_TIMEOUT,
36
+ ) -> None:
37
+ self._api_key = resolve_api_key(api_key)
38
+ self._base_url = base_url.rstrip("/")
39
+ self._max_retries = max_retries
40
+ self._client = httpx.Client(
41
+ headers=build_headers(self._api_key),
42
+ timeout=timeout,
43
+ )
44
+
45
+ def close(self) -> None:
46
+ """Close the underlying HTTP connection pool."""
47
+ self._client.close()
48
+
49
+ def __enter__(self) -> "ClassiFinder":
50
+ return self
51
+
52
+ def __exit__(self, *args) -> None:
53
+ self.close()
54
+
55
+ def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
56
+ """Make an HTTP request with retry logic."""
57
+ url = f"{self._base_url}{path}"
58
+ last_exc: Optional[Exception] = None
59
+
60
+ for attempt in range(self._max_retries + 1):
61
+ try:
62
+ response = self._client.request(method, url, **kwargs)
63
+ raise_for_status(response)
64
+ return response
65
+ except (httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError) as exc:
66
+ api_exc = APIConnectionError(str(exc))
67
+ last_exc = api_exc
68
+ if attempt >= self._max_retries:
69
+ raise api_exc from exc
70
+ sleep_for_retry(attempt, api_exc)
71
+ except Exception as exc:
72
+ last_exc = exc
73
+ if not is_retryable(exc) or attempt >= self._max_retries:
74
+ raise
75
+ sleep_for_retry(attempt, exc)
76
+
77
+ raise last_exc # pragma: no cover
78
+
79
+ def scan(
80
+ self,
81
+ text: str,
82
+ types: Optional[List[str]] = None,
83
+ min_confidence: float = 0.5,
84
+ include_context: bool = True,
85
+ ) -> ScanResult:
86
+ """Scan text for secrets."""
87
+ body = {
88
+ "text": text,
89
+ "types": types or ["all"],
90
+ "min_confidence": min_confidence,
91
+ "include_context": include_context,
92
+ }
93
+ response = self._request("POST", "/v1/scan", json=body)
94
+ return ScanResult.model_validate(response.json())
95
+
96
+ def redact(
97
+ self,
98
+ text: str,
99
+ types: Optional[List[str]] = None,
100
+ min_confidence: float = 0.5,
101
+ redaction_style: str = "label",
102
+ ) -> RedactResult:
103
+ """Scan and redact secrets from text."""
104
+ body = {
105
+ "text": text,
106
+ "types": types or ["all"],
107
+ "min_confidence": min_confidence,
108
+ "redaction_style": redaction_style,
109
+ }
110
+ response = self._request("POST", "/v1/redact", json=body)
111
+ return RedactResult.model_validate(response.json())
112
+
113
+ def get_types(self) -> TypesResult:
114
+ """List all detectable secret types."""
115
+ response = self._request("GET", "/v1/types")
116
+ return TypesResult.model_validate(response.json())
117
+
118
+ def health(self) -> HealthResult:
119
+ """Check API health."""
120
+ response = self._request("GET", "/v1/health")
121
+ return HealthResult.model_validate(response.json())
122
+
123
+ def feedback(
124
+ self,
125
+ request_id: str,
126
+ finding_id: str,
127
+ feedback_type: str,
128
+ comment: Optional[str] = None,
129
+ ) -> FeedbackResult:
130
+ """Report a false positive or false negative."""
131
+ body = {
132
+ "request_id": request_id,
133
+ "finding_id": finding_id,
134
+ "feedback_type": feedback_type,
135
+ }
136
+ if comment is not None:
137
+ body["comment"] = comment
138
+ response = self._request("POST", "/v1/feedback", json=body)
139
+ return FeedbackResult.model_validate(response.json())
@@ -0,0 +1,67 @@
1
+ """ClassiFinder SDK exception hierarchy."""
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class ClassiFinderError(Exception):
7
+ """Base exception for all ClassiFinder SDK errors."""
8
+
9
+ def __init__(self, message: str, status_code: Optional[int] = None) -> None:
10
+ self.message = message
11
+ self.status_code = status_code
12
+ super().__init__(message)
13
+
14
+
15
+ class AuthenticationError(ClassiFinderError):
16
+ """401 — Invalid or missing API key."""
17
+
18
+ def __init__(self, message: str) -> None:
19
+ super().__init__(message, status_code=401)
20
+
21
+
22
+ class RateLimitError(ClassiFinderError):
23
+ """429 — Rate limit exceeded."""
24
+
25
+ def __init__(self, message: str, retry_after: int = 0) -> None:
26
+ self.retry_after = retry_after
27
+ super().__init__(message, status_code=429)
28
+
29
+
30
+ class InvalidRequestError(ClassiFinderError):
31
+ """400 — Bad request (malformed input, payload too large, etc.)."""
32
+
33
+ def __init__(self, message: str, code: str = "invalid_request") -> None:
34
+ self.code = code
35
+ super().__init__(message, status_code=400)
36
+
37
+
38
+ class ForbiddenError(ClassiFinderError):
39
+ """403 — Feature not available on current tier."""
40
+
41
+ def __init__(self, message: str, code: str = "tier_limit_exceeded") -> None:
42
+ self.code = code
43
+ super().__init__(message, status_code=403)
44
+
45
+
46
+ class ServerError(ClassiFinderError):
47
+ """500 — Unexpected server-side error."""
48
+
49
+ def __init__(self, message: str) -> None:
50
+ super().__init__(message, status_code=500)
51
+
52
+
53
+ class APIConnectionError(ClassiFinderError):
54
+ """Network failure, timeout, or DNS resolution failure."""
55
+
56
+ def __init__(self, message: str) -> None:
57
+ super().__init__(message, status_code=None)
58
+
59
+
60
+ class SecretsDetectedError(ClassiFinderError):
61
+ """Raised by ClassiFinderGuard in block mode when secrets are found."""
62
+
63
+ def __init__(self, message: str, findings_count: int, findings: Any, summary: Any) -> None:
64
+ self.findings_count = findings_count
65
+ self.findings = findings
66
+ self.summary = summary
67
+ super().__init__(message, status_code=None)
@@ -0,0 +1,88 @@
1
+ """Pydantic v2 response models for the ClassiFinder API."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+
8
+ class _Base(BaseModel):
9
+ model_config = ConfigDict(extra="ignore")
10
+
11
+
12
+ class Span(_Base):
13
+ start: int
14
+ end: int
15
+
16
+
17
+ class SeveritySummary(_Base):
18
+ critical: int = 0
19
+ high: int = 0
20
+ medium: int = 0
21
+ low: int = 0
22
+
23
+
24
+ class Finding(_Base):
25
+ id: str
26
+ type: str
27
+ type_name: str
28
+ provider: str
29
+ severity: str
30
+ confidence: float
31
+ value_preview: str
32
+ span: Span
33
+ context: Optional[str] = None
34
+ is_likely_test_value: bool
35
+ recommendation: str
36
+ matched_pattern: str
37
+
38
+
39
+ class RedactFinding(_Base):
40
+ id: str
41
+ type: str
42
+ severity: str
43
+ confidence: float
44
+ span: Span
45
+ redacted_as: str
46
+
47
+
48
+ class TypeInfo(_Base):
49
+ id: str
50
+ name: str
51
+ provider: str
52
+ severity: str
53
+ description: str
54
+ tags: List[str] = []
55
+
56
+
57
+ class ScanResult(_Base):
58
+ request_id: str
59
+ scan_time_ms: int
60
+ findings_count: int
61
+ findings: List[Finding]
62
+ summary: SeveritySummary
63
+
64
+
65
+ class RedactResult(_Base):
66
+ request_id: str
67
+ scan_time_ms: int
68
+ findings_count: int
69
+ redacted_text: str
70
+ findings: List[RedactFinding]
71
+ summary: SeveritySummary
72
+
73
+
74
+ class TypesResult(_Base):
75
+ types_count: int
76
+ types: List[TypeInfo]
77
+
78
+
79
+ class HealthResult(_Base):
80
+ status: str
81
+ version: str
82
+ patterns_loaded: int
83
+ uptime_seconds: int
84
+
85
+
86
+ class FeedbackResult(_Base):
87
+ feedback_id: str
88
+ status: str
File without changes
@@ -0,0 +1,126 @@
1
+ """LangChain integration — ClassiFinderGuard Runnable."""
2
+
3
+ from typing import Any, List, Optional
4
+
5
+ from pydantic import ConfigDict, PrivateAttr
6
+
7
+ try:
8
+ from langchain_core.runnables import RunnableSerializable
9
+ except ImportError:
10
+ raise ImportError(
11
+ "langchain-core is required for the LangChain integration. "
12
+ "Install it with: pip install classifinder[langchain]"
13
+ )
14
+
15
+ from .._client import ClassiFinder
16
+ from .._async_client import AsyncClassiFinder
17
+ from .._exceptions import SecretsDetectedError
18
+ from .._models import ScanResult
19
+
20
+
21
+ class ClassiFinderGuard(RunnableSerializable[str, str]):
22
+ """A LangChain Runnable that scans/redacts secrets from text.
23
+
24
+ In redact mode (default), secrets are replaced and the clean text is
25
+ passed downstream. In block mode, an exception is raised if secrets
26
+ are found.
27
+ """
28
+
29
+ model_config = ConfigDict(arbitrary_types_allowed=True)
30
+
31
+ api_key: Optional[str] = None
32
+ mode: str = "redact"
33
+ redaction_style: str = "label"
34
+ types: List[str] = ["all"]
35
+ min_confidence: float = 0.5
36
+ base_url: str = "https://api.classifinder.tech"
37
+ max_retries: int = 2
38
+ timeout: float = 30.0
39
+
40
+ # Lazy-initialized clients (private attrs)
41
+ _sync_client: Optional[ClassiFinder] = PrivateAttr(default=None)
42
+ _async_client: Optional[AsyncClassiFinder] = PrivateAttr(default=None)
43
+
44
+ def _get_sync_client(self) -> ClassiFinder:
45
+ if self._sync_client is None:
46
+ self._sync_client = ClassiFinder(
47
+ api_key=self.api_key,
48
+ base_url=self.base_url,
49
+ max_retries=self.max_retries,
50
+ timeout=self.timeout,
51
+ )
52
+ return self._sync_client
53
+
54
+ def _get_async_client(self) -> AsyncClassiFinder:
55
+ if self._async_client is None:
56
+ self._async_client = AsyncClassiFinder(
57
+ api_key=self.api_key,
58
+ base_url=self.base_url,
59
+ max_retries=self.max_retries,
60
+ timeout=self.timeout,
61
+ )
62
+ return self._async_client
63
+
64
+ def _coerce_input(self, input: Any) -> str:
65
+ """Convert input to string, handling PromptValue objects."""
66
+ if isinstance(input, str):
67
+ return input
68
+ if hasattr(input, "to_string"):
69
+ return input.to_string()
70
+ return str(input)
71
+
72
+ def invoke(self, input: Any, config: Any = None, **kwargs) -> str:
73
+ """Sync: scan/redact text and return result."""
74
+ text = self._coerce_input(input)
75
+ client = self._get_sync_client()
76
+
77
+ if self.mode == "block":
78
+ result = client.scan(
79
+ text=text,
80
+ types=self.types,
81
+ min_confidence=self.min_confidence,
82
+ )
83
+ if result.findings_count > 0:
84
+ raise SecretsDetectedError(
85
+ message=f"Found {result.findings_count} secret(s) in input text.",
86
+ findings_count=result.findings_count,
87
+ findings=result.findings,
88
+ summary=result.summary,
89
+ )
90
+ return text
91
+ else:
92
+ result = client.redact(
93
+ text=text,
94
+ types=self.types,
95
+ min_confidence=self.min_confidence,
96
+ redaction_style=self.redaction_style,
97
+ )
98
+ return result.redacted_text
99
+
100
+ async def ainvoke(self, input: Any, config: Any = None, **kwargs) -> str:
101
+ """Async: scan/redact text and return result."""
102
+ text = self._coerce_input(input)
103
+ client = self._get_async_client()
104
+
105
+ if self.mode == "block":
106
+ result = await client.scan(
107
+ text=text,
108
+ types=self.types,
109
+ min_confidence=self.min_confidence,
110
+ )
111
+ if result.findings_count > 0:
112
+ raise SecretsDetectedError(
113
+ message=f"Found {result.findings_count} secret(s) in input text.",
114
+ findings_count=result.findings_count,
115
+ findings=result.findings,
116
+ summary=result.summary,
117
+ )
118
+ return text
119
+ else:
120
+ result = await client.redact(
121
+ text=text,
122
+ types=self.types,
123
+ min_confidence=self.min_confidence,
124
+ redaction_style=self.redaction_style,
125
+ )
126
+ return result.redacted_text
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: classifinder
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the ClassiFinder secret detection API
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: pydantic>=2.7.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.22.0; extra == 'dev'
14
+ Provides-Extra: langchain
15
+ Requires-Dist: langchain-core>=0.3.0; extra == 'langchain'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # ClassiFinder
19
+
20
+ Python SDK for the ClassiFinder secret detection API.
21
+
22
+ Scan text for leaked secrets and credentials, get structured findings, and redact
23
+ sensitive values -- all in a few lines of Python.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install classifinder
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ ```python
34
+ from classifinder import ClassiFinder
35
+
36
+ client = ClassiFinder(api_key="ss_live_...")
37
+ # or set the CLASSIFINDER_API_KEY environment variable
38
+
39
+ result = client.scan("My AWS key is AKIAIOSFODNN7EXAMPLE")
40
+
41
+ for finding in result.findings:
42
+ print(f"{finding.type_name} (severity={finding.severity}, confidence={finding.confidence})")
43
+ print(f" Preview: {finding.value_preview}")
44
+ ```
45
+
46
+ ## Redact Secrets
47
+
48
+ The `/v1/redact` endpoint replaces secrets in-place so you can safely pass text
49
+ downstream to LLMs or logging systems.
50
+
51
+ ```python
52
+ result = client.redact("DB password is SuperSecret123!")
53
+
54
+ print(result.redacted_text)
55
+ # "DB password is [DATABASE_PASSWORD]!"
56
+
57
+ print(f"Redacted {result.findings_count} secret(s)")
58
+ ```
59
+
60
+ ## Async Support
61
+
62
+ ```python
63
+ from classifinder import AsyncClassiFinder
64
+
65
+ async def main():
66
+ client = AsyncClassiFinder(api_key="ss_live_...")
67
+ result = await client.scan("AKIA...")
68
+ await client.close()
69
+ ```
70
+
71
+ ## LangChain Integration
72
+
73
+ Guard your LLM chains against secret leakage.
74
+
75
+ ```bash
76
+ pip install classifinder[langchain]
77
+ ```
78
+
79
+ ```python
80
+ from classifinder.integrations.langchain import ClassiFinderGuard
81
+
82
+ guard = ClassiFinderGuard(api_key="ss_live_...", mode="redact")
83
+
84
+ # Use as a standalone runnable
85
+ clean_text = guard.invoke("My token is ghp_abc123secret")
86
+
87
+ # Chain with other LangChain runnables
88
+ chain = guard | your_llm | output_parser
89
+ ```
90
+
91
+ Set `mode="block"` to raise `SecretsDetectedError` instead of redacting.
92
+
93
+ ## Error Handling
94
+
95
+ ```python
96
+ from classifinder import ClassiFinder, AuthenticationError, RateLimitError, ClassiFinderError
97
+
98
+ client = ClassiFinder(api_key="ss_live_...")
99
+
100
+ try:
101
+ result = client.scan("check this text")
102
+ except AuthenticationError:
103
+ print("Invalid API key")
104
+ except RateLimitError as e:
105
+ print(f"Rate limited. Retry after {e.retry_after}s")
106
+ except ClassiFinderError as e:
107
+ print(f"API error ({e.status_code}): {e.message}")
108
+ ```
109
+
110
+ ## Documentation
111
+
112
+ Full API documentation: [https://classifinder.tech](https://classifinder.tech)
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,12 @@
1
+ classifinder/__init__.py,sha256=mq-nsQMqzGoe7msavDm45CXsgcAYX3_GFj4DusV8Lh8,991
2
+ classifinder/_async_client.py,sha256=rZ_uiml-6Zl7g3G5Q0e08fq51Of0jgEoCsr9J0xyx8E,4454
3
+ classifinder/_base.py,sha256=EN8i6gOUeeH2tYbKxDj71C7MLPrDhqyJ6fQI2cYb4po,2942
4
+ classifinder/_client.py,sha256=aQv-WfrRtbxT4zuz0ZUc3Z3WaUCBsCivHmRgs7doQ9s,4302
5
+ classifinder/_exceptions.py,sha256=cPfm9iN4g0LkPYX18L_x0q2XK4mzWjE9xlnotUDiBXA,2105
6
+ classifinder/_models.py,sha256=lVQUrexCzEgPPvQVCxA_y5TAGwvpqWym0Gruq13xQXQ,1498
7
+ classifinder/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ classifinder/integrations/langchain.py,sha256=4jvzg3DkrR0k_TjnZCUTdKjBjRjMSULjLbWoE_PB--Q,4443
9
+ classifinder-0.1.0.dist-info/METADATA,sha256=JAs7T7Xl02vQ3e2iJ_Bdz45ddIwk1KFIhIs5CEOobtw,2824
10
+ classifinder-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ classifinder-0.1.0.dist-info/licenses/LICENSE,sha256=wmUJj88M_IbU6tJEONwZc5pKPwJwbmWC8SgFcgoAgRs,1069
12
+ classifinder-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ClassiFinder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.