ciphyrs 1.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.
- ciphyrs/__init__.py +31 -0
- ciphyrs/_http.py +183 -0
- ciphyrs/_types.py +94 -0
- ciphyrs/client.py +313 -0
- ciphyrs/errors.py +107 -0
- ciphyrs/integrations/__init__.py +1 -0
- ciphyrs/integrations/crewai.py +465 -0
- ciphyrs/integrations/langchain.py +572 -0
- ciphyrs-1.1.0.dist-info/METADATA +152 -0
- ciphyrs-1.1.0.dist-info/RECORD +11 -0
- ciphyrs-1.1.0.dist-info/WHEEL +4 -0
ciphyrs/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Ciphyrs PII Shield Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncCiphyrsClient, CiphyrsClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
CiphyrsAuthError,
|
|
6
|
+
CiphyrsError,
|
|
7
|
+
CiphyrsJobTimeoutError,
|
|
8
|
+
CiphyrsNotFoundError,
|
|
9
|
+
CiphyrsPermissionError,
|
|
10
|
+
CiphyrsRateLimitError,
|
|
11
|
+
CiphyrsTimeoutError,
|
|
12
|
+
)
|
|
13
|
+
from ._types import AsyncMaskResult, JobResult, MaskResult, RestoreResult
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CiphyrsClient",
|
|
17
|
+
"AsyncCiphyrsClient",
|
|
18
|
+
"CiphyrsError",
|
|
19
|
+
"CiphyrsAuthError",
|
|
20
|
+
"CiphyrsPermissionError",
|
|
21
|
+
"CiphyrsNotFoundError",
|
|
22
|
+
"CiphyrsRateLimitError",
|
|
23
|
+
"CiphyrsTimeoutError",
|
|
24
|
+
"CiphyrsJobTimeoutError",
|
|
25
|
+
"MaskResult",
|
|
26
|
+
"RestoreResult",
|
|
27
|
+
"AsyncMaskResult",
|
|
28
|
+
"JobResult",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "1.1.0"
|
ciphyrs/_http.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Internal HTTP transport with retry logic for the Ciphyrs SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .errors import (
|
|
12
|
+
CiphyrsAuthError,
|
|
13
|
+
CiphyrsError,
|
|
14
|
+
CiphyrsNotFoundError,
|
|
15
|
+
CiphyrsPermissionError,
|
|
16
|
+
CiphyrsRateLimitError,
|
|
17
|
+
CiphyrsTimeoutError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_MAX_RETRIES = 3
|
|
21
|
+
_BACKOFF_BASE = 0.5
|
|
22
|
+
_BACKOFF_FACTOR = 2.0
|
|
23
|
+
_RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_error_body(response: httpx.Response) -> tuple[str, str | None]:
|
|
27
|
+
"""Extract message and error code from an error response body."""
|
|
28
|
+
try:
|
|
29
|
+
body = response.json()
|
|
30
|
+
message = body.get("error", body.get("message", response.reason_phrase or "Unknown error"))
|
|
31
|
+
code = body.get("code")
|
|
32
|
+
except Exception:
|
|
33
|
+
message = response.reason_phrase or "Unknown error"
|
|
34
|
+
code = None
|
|
35
|
+
return message, code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
39
|
+
"""Map HTTP status codes to typed SDK exceptions."""
|
|
40
|
+
if response.is_success:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
status = response.status_code
|
|
44
|
+
message, code = _parse_error_body(response)
|
|
45
|
+
|
|
46
|
+
if status == 401:
|
|
47
|
+
raise CiphyrsAuthError(message=message, code=code)
|
|
48
|
+
if status == 403:
|
|
49
|
+
raise CiphyrsPermissionError(message=message, code=code)
|
|
50
|
+
if status == 404:
|
|
51
|
+
raise CiphyrsNotFoundError(message=message, code=code)
|
|
52
|
+
if status == 429:
|
|
53
|
+
retry_after_raw = response.headers.get("retry-after")
|
|
54
|
+
retry_after: float | None = None
|
|
55
|
+
if retry_after_raw is not None:
|
|
56
|
+
try:
|
|
57
|
+
retry_after = float(retry_after_raw)
|
|
58
|
+
except (ValueError, TypeError):
|
|
59
|
+
retry_after = None
|
|
60
|
+
raise CiphyrsRateLimitError(message=message, retry_after=retry_after, code=code)
|
|
61
|
+
|
|
62
|
+
raise CiphyrsError(message=message, status=status, code=code)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _backoff_delay(attempt: int, retry_after: float | None = None) -> float:
|
|
66
|
+
"""Calculate delay for the given retry attempt, respecting Retry-After if present."""
|
|
67
|
+
base_delay = _BACKOFF_BASE * (_BACKOFF_FACTOR ** attempt)
|
|
68
|
+
if retry_after is not None and retry_after > base_delay:
|
|
69
|
+
return retry_after
|
|
70
|
+
return base_delay
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SyncHTTPClient:
|
|
74
|
+
"""Synchronous HTTP client with automatic retries."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, base_url: str, api_key: str, timeout: float) -> None:
|
|
77
|
+
self._client = httpx.Client(
|
|
78
|
+
base_url=base_url,
|
|
79
|
+
headers={
|
|
80
|
+
"x-api-key": api_key,
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
"User-Agent": "ciphyrs-python/1.0.0",
|
|
83
|
+
},
|
|
84
|
+
timeout=httpx.Timeout(timeout),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
88
|
+
last_exc: Exception | None = None
|
|
89
|
+
|
|
90
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
91
|
+
try:
|
|
92
|
+
response = self._client.request(method, path, **kwargs)
|
|
93
|
+
except httpx.TimeoutException as exc:
|
|
94
|
+
if attempt < _MAX_RETRIES:
|
|
95
|
+
time.sleep(_backoff_delay(attempt))
|
|
96
|
+
last_exc = exc
|
|
97
|
+
continue
|
|
98
|
+
raise CiphyrsTimeoutError() from exc
|
|
99
|
+
except httpx.HTTPError as exc:
|
|
100
|
+
raise CiphyrsError(str(exc)) from exc
|
|
101
|
+
|
|
102
|
+
if response.status_code in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES:
|
|
103
|
+
retry_after: float | None = None
|
|
104
|
+
if response.status_code == 429:
|
|
105
|
+
raw = response.headers.get("retry-after")
|
|
106
|
+
if raw is not None:
|
|
107
|
+
try:
|
|
108
|
+
retry_after = float(raw)
|
|
109
|
+
except (ValueError, TypeError):
|
|
110
|
+
pass
|
|
111
|
+
time.sleep(_backoff_delay(attempt, retry_after))
|
|
112
|
+
last_exc = None
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
_raise_for_status(response)
|
|
116
|
+
|
|
117
|
+
if response.status_code == 204:
|
|
118
|
+
return {}
|
|
119
|
+
return response.json() # type: ignore[no-any-return]
|
|
120
|
+
|
|
121
|
+
# Should not reach here, but just in case
|
|
122
|
+
if last_exc is not None:
|
|
123
|
+
raise CiphyrsTimeoutError() from last_exc
|
|
124
|
+
raise CiphyrsError("Max retries exceeded")
|
|
125
|
+
|
|
126
|
+
def close(self) -> None:
|
|
127
|
+
self._client.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AsyncHTTPClient:
|
|
131
|
+
"""Asynchronous HTTP client with automatic retries."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, base_url: str, api_key: str, timeout: float) -> None:
|
|
134
|
+
self._client = httpx.AsyncClient(
|
|
135
|
+
base_url=base_url,
|
|
136
|
+
headers={
|
|
137
|
+
"x-api-key": api_key,
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
"User-Agent": "ciphyrs-python/1.0.0",
|
|
140
|
+
},
|
|
141
|
+
timeout=httpx.Timeout(timeout),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
async def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
145
|
+
last_exc: Exception | None = None
|
|
146
|
+
|
|
147
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
148
|
+
try:
|
|
149
|
+
response = await self._client.request(method, path, **kwargs)
|
|
150
|
+
except httpx.TimeoutException as exc:
|
|
151
|
+
if attempt < _MAX_RETRIES:
|
|
152
|
+
await asyncio.sleep(_backoff_delay(attempt))
|
|
153
|
+
last_exc = exc
|
|
154
|
+
continue
|
|
155
|
+
raise CiphyrsTimeoutError() from exc
|
|
156
|
+
except httpx.HTTPError as exc:
|
|
157
|
+
raise CiphyrsError(str(exc)) from exc
|
|
158
|
+
|
|
159
|
+
if response.status_code in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES:
|
|
160
|
+
retry_after: float | None = None
|
|
161
|
+
if response.status_code == 429:
|
|
162
|
+
raw = response.headers.get("retry-after")
|
|
163
|
+
if raw is not None:
|
|
164
|
+
try:
|
|
165
|
+
retry_after = float(raw)
|
|
166
|
+
except (ValueError, TypeError):
|
|
167
|
+
pass
|
|
168
|
+
await asyncio.sleep(_backoff_delay(attempt, retry_after))
|
|
169
|
+
last_exc = None
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
_raise_for_status(response)
|
|
173
|
+
|
|
174
|
+
if response.status_code == 204:
|
|
175
|
+
return {}
|
|
176
|
+
return response.json() # type: ignore[no-any-return]
|
|
177
|
+
|
|
178
|
+
if last_exc is not None:
|
|
179
|
+
raise CiphyrsTimeoutError() from last_exc
|
|
180
|
+
raise CiphyrsError("Max retries exceeded")
|
|
181
|
+
|
|
182
|
+
async def close(self) -> None:
|
|
183
|
+
await self._client.aclose()
|
ciphyrs/_types.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Data classes for Ciphyrs API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class MaskResult:
|
|
11
|
+
"""Result of a synchronous mask operation."""
|
|
12
|
+
|
|
13
|
+
masked_text: str
|
|
14
|
+
session_id: str
|
|
15
|
+
entities_found: list[str] = field(default_factory=list)
|
|
16
|
+
entity_summary: dict[str, int] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_dict(cls, data: dict[str, Any]) -> MaskResult:
|
|
20
|
+
return cls(
|
|
21
|
+
masked_text=data["masked_text"],
|
|
22
|
+
session_id=data["session_id"],
|
|
23
|
+
entities_found=data.get("entities_found", []),
|
|
24
|
+
entity_summary=data.get("entity_summary", {}),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RestoreResult:
|
|
30
|
+
"""Result of a restore operation."""
|
|
31
|
+
|
|
32
|
+
restored_text: str
|
|
33
|
+
tokens_restored: int
|
|
34
|
+
purged: bool = False
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict[str, Any]) -> RestoreResult:
|
|
38
|
+
return cls(
|
|
39
|
+
restored_text=data["restored_text"],
|
|
40
|
+
tokens_restored=data.get("tokens_restored", 0),
|
|
41
|
+
purged=data.get("purged", False),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class AsyncMaskResult:
|
|
47
|
+
"""Result of submitting an async mask job."""
|
|
48
|
+
|
|
49
|
+
job_id: str
|
|
50
|
+
session_id: str
|
|
51
|
+
status: str
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any]) -> AsyncMaskResult:
|
|
55
|
+
return cls(
|
|
56
|
+
job_id=data["job_id"],
|
|
57
|
+
session_id=data["session_id"],
|
|
58
|
+
status=data.get("status", "pending"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class JobResult:
|
|
64
|
+
"""Result of polling a job."""
|
|
65
|
+
|
|
66
|
+
job_id: str
|
|
67
|
+
status: str
|
|
68
|
+
session_id: str | None = None
|
|
69
|
+
masked_text: str | None = None
|
|
70
|
+
entities_found: list[str] = field(default_factory=list)
|
|
71
|
+
entity_summary: dict[str, int] = field(default_factory=dict)
|
|
72
|
+
latency_ms: float | None = None
|
|
73
|
+
error: str | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_complete(self) -> bool:
|
|
77
|
+
return self.status == "completed"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_failed(self) -> bool:
|
|
81
|
+
return self.status == "failed"
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict[str, Any]) -> JobResult:
|
|
85
|
+
return cls(
|
|
86
|
+
job_id=data["job_id"],
|
|
87
|
+
status=data["status"],
|
|
88
|
+
session_id=data.get("session_id"),
|
|
89
|
+
masked_text=data.get("masked_text"),
|
|
90
|
+
entities_found=data.get("entities_found", []),
|
|
91
|
+
entity_summary=data.get("entity_summary", {}),
|
|
92
|
+
latency_ms=data.get("latency_ms"),
|
|
93
|
+
error=data.get("error"),
|
|
94
|
+
)
|
ciphyrs/client.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Ciphyrs PII Shield API client (sync and async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._http import AsyncHTTPClient, SyncHTTPClient
|
|
10
|
+
from ._types import AsyncMaskResult, JobResult, MaskResult, RestoreResult
|
|
11
|
+
from .errors import CiphyrsJobTimeoutError
|
|
12
|
+
|
|
13
|
+
_DEFAULT_BASE_URL = "https://www.ciphyrs.com"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_mask_payload(
|
|
17
|
+
text: str,
|
|
18
|
+
session_id: str | None,
|
|
19
|
+
source: str,
|
|
20
|
+
surface: str,
|
|
21
|
+
entities: list[str] | None,
|
|
22
|
+
llm_provider: str | None,
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
payload: dict[str, Any] = {
|
|
25
|
+
"text": text,
|
|
26
|
+
"source": source,
|
|
27
|
+
"surface": surface,
|
|
28
|
+
}
|
|
29
|
+
if session_id is not None:
|
|
30
|
+
payload["session_id"] = session_id
|
|
31
|
+
if entities is not None:
|
|
32
|
+
payload["entities"] = entities
|
|
33
|
+
if llm_provider is not None:
|
|
34
|
+
payload["llm_provider"] = llm_provider
|
|
35
|
+
return payload
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CiphyrsClient:
|
|
39
|
+
"""Synchronous client for the Ciphyrs PII Shield API.
|
|
40
|
+
|
|
41
|
+
Usage::
|
|
42
|
+
|
|
43
|
+
client = CiphyrsClient(api_key="cprs_...")
|
|
44
|
+
result = client.mask("My SSN is 123-45-6789")
|
|
45
|
+
print(result.masked_text)
|
|
46
|
+
client.close()
|
|
47
|
+
|
|
48
|
+
Can also be used as a context manager::
|
|
49
|
+
|
|
50
|
+
with CiphyrsClient(api_key="cprs_...") as client:
|
|
51
|
+
result = client.mask("My SSN is 123-45-6789")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: str,
|
|
57
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
58
|
+
timeout: float = 10.0,
|
|
59
|
+
) -> None:
|
|
60
|
+
if not api_key:
|
|
61
|
+
raise ValueError("api_key must be a non-empty string")
|
|
62
|
+
self._http = SyncHTTPClient(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
63
|
+
|
|
64
|
+
def __enter__(self) -> CiphyrsClient:
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __exit__(self, *args: Any) -> None:
|
|
68
|
+
self.close()
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
"""Close the underlying HTTP connection pool."""
|
|
72
|
+
self._http.close()
|
|
73
|
+
|
|
74
|
+
# ── Mask ──────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
def mask(
|
|
77
|
+
self,
|
|
78
|
+
text: str,
|
|
79
|
+
session_id: str | None = None,
|
|
80
|
+
source: str = "PROMPT",
|
|
81
|
+
surface: str = "sdk",
|
|
82
|
+
entities: list[str] | None = None,
|
|
83
|
+
llm_provider: str | None = None,
|
|
84
|
+
) -> MaskResult:
|
|
85
|
+
"""Mask PII in text synchronously.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
text: The text to scan and mask.
|
|
89
|
+
session_id: Optional session identifier for grouping related requests.
|
|
90
|
+
source: Origin label (e.g. ``"PROMPT"``, ``"RESPONSE"``).
|
|
91
|
+
surface: Integration surface (default ``"sdk"``).
|
|
92
|
+
entities: Optional list of entity types to detect.
|
|
93
|
+
llm_provider: Optional LLM provider hint.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A :class:`MaskResult` with the masked text and metadata.
|
|
97
|
+
"""
|
|
98
|
+
payload = _build_mask_payload(text, session_id, source, surface, entities, llm_provider)
|
|
99
|
+
data = self._http.request("POST", "/v1/scan/mask", json=payload)
|
|
100
|
+
return MaskResult.from_dict(data)
|
|
101
|
+
|
|
102
|
+
# ── Async Mask ────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def mask_async(
|
|
105
|
+
self,
|
|
106
|
+
text: str,
|
|
107
|
+
session_id: str | None = None,
|
|
108
|
+
source: str = "PROMPT",
|
|
109
|
+
surface: str = "sdk",
|
|
110
|
+
entities: list[str] | None = None,
|
|
111
|
+
llm_provider: str | None = None,
|
|
112
|
+
) -> AsyncMaskResult:
|
|
113
|
+
"""Submit an asynchronous mask job.
|
|
114
|
+
|
|
115
|
+
Returns immediately with a job ID that can be polled via
|
|
116
|
+
:meth:`get_job` or awaited via :meth:`wait_for_job`.
|
|
117
|
+
"""
|
|
118
|
+
payload = _build_mask_payload(text, session_id, source, surface, entities, llm_provider)
|
|
119
|
+
data = self._http.request("POST", "/v1/scan/mask/async", json=payload)
|
|
120
|
+
return AsyncMaskResult.from_dict(data)
|
|
121
|
+
|
|
122
|
+
# ── Restore ───────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def restore(self, masked_text: str, session_id: str, *, purge: bool = False) -> RestoreResult:
|
|
125
|
+
"""Restore previously masked text back to its original form.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
masked_text: The masked text containing placeholder tokens.
|
|
129
|
+
session_id: The session ID returned from the original mask call.
|
|
130
|
+
purge: If True, delete tokens from the vault after restore.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A :class:`RestoreResult` with the restored text.
|
|
134
|
+
"""
|
|
135
|
+
payload: dict[str, Any] = {"text": masked_text, "session_id": session_id, "purge": purge}
|
|
136
|
+
data = self._http.request("POST", "/v1/scan/restore", json=payload)
|
|
137
|
+
return RestoreResult.from_dict(data)
|
|
138
|
+
|
|
139
|
+
# ── Jobs ──────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def get_job(self, job_id: str) -> JobResult:
|
|
142
|
+
"""Get the current status and result of an async job."""
|
|
143
|
+
data = self._http.request("GET", f"/v1/scan/jobs/{job_id}")
|
|
144
|
+
return JobResult.from_dict(data)
|
|
145
|
+
|
|
146
|
+
def wait_for_job(
|
|
147
|
+
self,
|
|
148
|
+
job_id: str,
|
|
149
|
+
poll_interval: float = 0.5,
|
|
150
|
+
timeout: float = 60.0,
|
|
151
|
+
) -> JobResult:
|
|
152
|
+
"""Poll an async job until it completes or the timeout is reached.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
job_id: The job ID to poll.
|
|
156
|
+
poll_interval: Seconds between poll requests.
|
|
157
|
+
timeout: Maximum seconds to wait before raising :class:`CiphyrsJobTimeoutError`.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The final :class:`JobResult`.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
CiphyrsJobTimeoutError: If the job does not complete within *timeout* seconds.
|
|
164
|
+
"""
|
|
165
|
+
deadline = time.monotonic() + timeout
|
|
166
|
+
while True:
|
|
167
|
+
result = self.get_job(job_id)
|
|
168
|
+
if result.is_complete or result.is_failed:
|
|
169
|
+
return result
|
|
170
|
+
if time.monotonic() >= deadline:
|
|
171
|
+
raise CiphyrsJobTimeoutError(job_id, timeout)
|
|
172
|
+
time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))
|
|
173
|
+
|
|
174
|
+
# ── API Keys ──────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def create_key(
|
|
177
|
+
self,
|
|
178
|
+
name: str | None = None,
|
|
179
|
+
scopes: list[str] | None = None,
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
"""Create a new API key.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
name: Optional human-readable name for the key.
|
|
185
|
+
scopes: Optional list of permission scopes.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
A dict containing the new key details.
|
|
189
|
+
"""
|
|
190
|
+
payload: dict[str, Any] = {}
|
|
191
|
+
if name is not None:
|
|
192
|
+
payload["name"] = name
|
|
193
|
+
if scopes is not None:
|
|
194
|
+
payload["scopes"] = scopes
|
|
195
|
+
return self._http.request("POST", "/v1/auth/api-keys", json=payload)
|
|
196
|
+
|
|
197
|
+
def list_keys(self) -> dict[str, Any]:
|
|
198
|
+
"""List all API keys associated with the account."""
|
|
199
|
+
return self._http.request("GET", "/v1/auth/api-keys")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class AsyncCiphyrsClient:
|
|
203
|
+
"""Asynchronous client for the Ciphyrs PII Shield API.
|
|
204
|
+
|
|
205
|
+
Usage::
|
|
206
|
+
|
|
207
|
+
async with AsyncCiphyrsClient(api_key="cprs_...") as client:
|
|
208
|
+
result = await client.mask("My SSN is 123-45-6789")
|
|
209
|
+
print(result.masked_text)
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
api_key: str,
|
|
215
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
216
|
+
timeout: float = 10.0,
|
|
217
|
+
) -> None:
|
|
218
|
+
if not api_key:
|
|
219
|
+
raise ValueError("api_key must be a non-empty string")
|
|
220
|
+
self._http = AsyncHTTPClient(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
221
|
+
|
|
222
|
+
async def __aenter__(self) -> AsyncCiphyrsClient:
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
226
|
+
await self.close()
|
|
227
|
+
|
|
228
|
+
async def close(self) -> None:
|
|
229
|
+
"""Close the underlying HTTP connection pool."""
|
|
230
|
+
await self._http.close()
|
|
231
|
+
|
|
232
|
+
# ── Mask ──────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
async def mask(
|
|
235
|
+
self,
|
|
236
|
+
text: str,
|
|
237
|
+
session_id: str | None = None,
|
|
238
|
+
source: str = "PROMPT",
|
|
239
|
+
surface: str = "sdk",
|
|
240
|
+
entities: list[str] | None = None,
|
|
241
|
+
llm_provider: str | None = None,
|
|
242
|
+
) -> MaskResult:
|
|
243
|
+
"""Mask PII in text asynchronously."""
|
|
244
|
+
payload = _build_mask_payload(text, session_id, source, surface, entities, llm_provider)
|
|
245
|
+
data = await self._http.request("POST", "/v1/scan/mask", json=payload)
|
|
246
|
+
return MaskResult.from_dict(data)
|
|
247
|
+
|
|
248
|
+
# ── Async Mask ────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
async def mask_async(
|
|
251
|
+
self,
|
|
252
|
+
text: str,
|
|
253
|
+
session_id: str | None = None,
|
|
254
|
+
source: str = "PROMPT",
|
|
255
|
+
surface: str = "sdk",
|
|
256
|
+
entities: list[str] | None = None,
|
|
257
|
+
llm_provider: str | None = None,
|
|
258
|
+
) -> AsyncMaskResult:
|
|
259
|
+
"""Submit an asynchronous mask job."""
|
|
260
|
+
payload = _build_mask_payload(text, session_id, source, surface, entities, llm_provider)
|
|
261
|
+
data = await self._http.request("POST", "/v1/scan/mask/async", json=payload)
|
|
262
|
+
return AsyncMaskResult.from_dict(data)
|
|
263
|
+
|
|
264
|
+
# ── Restore ───────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
async def restore(self, masked_text: str, session_id: str, *, purge: bool = False) -> RestoreResult:
|
|
267
|
+
"""Restore previously masked text back to its original form."""
|
|
268
|
+
payload: dict[str, Any] = {"text": masked_text, "session_id": session_id, "purge": purge}
|
|
269
|
+
data = await self._http.request("POST", "/v1/scan/restore", json=payload)
|
|
270
|
+
return RestoreResult.from_dict(data)
|
|
271
|
+
|
|
272
|
+
# ── Jobs ──────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
async def get_job(self, job_id: str) -> JobResult:
|
|
275
|
+
"""Get the current status and result of an async job."""
|
|
276
|
+
data = await self._http.request("GET", f"/v1/scan/jobs/{job_id}")
|
|
277
|
+
return JobResult.from_dict(data)
|
|
278
|
+
|
|
279
|
+
async def wait_for_job(
|
|
280
|
+
self,
|
|
281
|
+
job_id: str,
|
|
282
|
+
poll_interval: float = 0.5,
|
|
283
|
+
timeout: float = 60.0,
|
|
284
|
+
) -> JobResult:
|
|
285
|
+
"""Poll an async job until it completes or the timeout is reached."""
|
|
286
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
287
|
+
while True:
|
|
288
|
+
result = await self.get_job(job_id)
|
|
289
|
+
if result.is_complete or result.is_failed:
|
|
290
|
+
return result
|
|
291
|
+
remaining = deadline - asyncio.get_event_loop().time()
|
|
292
|
+
if remaining <= 0:
|
|
293
|
+
raise CiphyrsJobTimeoutError(job_id, timeout)
|
|
294
|
+
await asyncio.sleep(min(poll_interval, remaining))
|
|
295
|
+
|
|
296
|
+
# ── API Keys ──────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
async def create_key(
|
|
299
|
+
self,
|
|
300
|
+
name: str | None = None,
|
|
301
|
+
scopes: list[str] | None = None,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Create a new API key."""
|
|
304
|
+
payload: dict[str, Any] = {}
|
|
305
|
+
if name is not None:
|
|
306
|
+
payload["name"] = name
|
|
307
|
+
if scopes is not None:
|
|
308
|
+
payload["scopes"] = scopes
|
|
309
|
+
return await self._http.request("POST", "/v1/auth/api-keys", json=payload)
|
|
310
|
+
|
|
311
|
+
async def list_keys(self) -> dict[str, Any]:
|
|
312
|
+
"""List all API keys associated with the account."""
|
|
313
|
+
return await self._http.request("GET", "/v1/auth/api-keys")
|