classifinder 0.1.0__tar.gz

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,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.
@@ -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,99 @@
1
+ # ClassiFinder
2
+
3
+ Python SDK for the ClassiFinder secret detection API.
4
+
5
+ Scan text for leaked secrets and credentials, get structured findings, and redact
6
+ sensitive values -- all in a few lines of Python.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install classifinder
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ from classifinder import ClassiFinder
18
+
19
+ client = ClassiFinder(api_key="ss_live_...")
20
+ # or set the CLASSIFINDER_API_KEY environment variable
21
+
22
+ result = client.scan("My AWS key is AKIAIOSFODNN7EXAMPLE")
23
+
24
+ for finding in result.findings:
25
+ print(f"{finding.type_name} (severity={finding.severity}, confidence={finding.confidence})")
26
+ print(f" Preview: {finding.value_preview}")
27
+ ```
28
+
29
+ ## Redact Secrets
30
+
31
+ The `/v1/redact` endpoint replaces secrets in-place so you can safely pass text
32
+ downstream to LLMs or logging systems.
33
+
34
+ ```python
35
+ result = client.redact("DB password is SuperSecret123!")
36
+
37
+ print(result.redacted_text)
38
+ # "DB password is [DATABASE_PASSWORD]!"
39
+
40
+ print(f"Redacted {result.findings_count} secret(s)")
41
+ ```
42
+
43
+ ## Async Support
44
+
45
+ ```python
46
+ from classifinder import AsyncClassiFinder
47
+
48
+ async def main():
49
+ client = AsyncClassiFinder(api_key="ss_live_...")
50
+ result = await client.scan("AKIA...")
51
+ await client.close()
52
+ ```
53
+
54
+ ## LangChain Integration
55
+
56
+ Guard your LLM chains against secret leakage.
57
+
58
+ ```bash
59
+ pip install classifinder[langchain]
60
+ ```
61
+
62
+ ```python
63
+ from classifinder.integrations.langchain import ClassiFinderGuard
64
+
65
+ guard = ClassiFinderGuard(api_key="ss_live_...", mode="redact")
66
+
67
+ # Use as a standalone runnable
68
+ clean_text = guard.invoke("My token is ghp_abc123secret")
69
+
70
+ # Chain with other LangChain runnables
71
+ chain = guard | your_llm | output_parser
72
+ ```
73
+
74
+ Set `mode="block"` to raise `SecretsDetectedError` instead of redacting.
75
+
76
+ ## Error Handling
77
+
78
+ ```python
79
+ from classifinder import ClassiFinder, AuthenticationError, RateLimitError, ClassiFinderError
80
+
81
+ client = ClassiFinder(api_key="ss_live_...")
82
+
83
+ try:
84
+ result = client.scan("check this text")
85
+ except AuthenticationError:
86
+ print("Invalid API key")
87
+ except RateLimitError as e:
88
+ print(f"Rate limited. Retry after {e.retry_after}s")
89
+ except ClassiFinderError as e:
90
+ print(f"API error ({e.status_code}): {e.message}")
91
+ ```
92
+
93
+ ## Documentation
94
+
95
+ Full API documentation: [https://classifinder.tech](https://classifinder.tech)
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "classifinder"
3
+ version = "0.1.0"
4
+ description = "Python SDK for the ClassiFinder secret detection API"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "httpx>=0.27.0",
10
+ "pydantic>=2.7.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ langchain = ["langchain-core>=0.3.0"]
15
+ dev = [
16
+ "pytest>=8.0.0",
17
+ "pytest-asyncio>=0.24.0",
18
+ "respx>=0.22.0",
19
+ ]
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/classifinder"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ asyncio_mode = "auto"
@@ -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())
@@ -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())