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 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")