ciphyrs 1.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,16 @@
1
+ node_modules/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+ *.tfstate
8
+ *.tfstate.*
9
+ .terraform/
10
+ .terraform.lock.hcl
11
+ dist/
12
+ build/
13
+ *.log
14
+ .DS_Store
15
+ extensions/vscode/*.vsix
16
+ extensions/vscode/out/
ciphyrs-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: ciphyrs
3
+ Version: 1.1.0
4
+ Summary: Python SDK for the Ciphyrs PII Shield API
5
+ Project-URL: Homepage, https://www.ciphyrs.com
6
+ Project-URL: Documentation, https://www.ciphyrs.com/docs
7
+ Project-URL: Repository, https://github.com/praveen190/Ciphyrs
8
+ Project-URL: Changelog, https://github.com/praveen190/Ciphyrs/releases
9
+ Author-email: Ciphyrs <support@ciphyrs.com>
10
+ License-Expression: MIT
11
+ Keywords: ciphyrs,masking,pii,privacy
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx>=0.25.0
26
+ Provides-Extra: all
27
+ Requires-Dist: crewai>=0.41.0; extra == 'all'
28
+ Requires-Dist: langchain-core>=0.2.0; extra == 'all'
29
+ Provides-Extra: crewai
30
+ Requires-Dist: crewai>=0.41.0; extra == 'crewai'
31
+ Provides-Extra: langchain
32
+ Requires-Dist: langchain-core>=0.2.0; extra == 'langchain'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Ciphyrs — PII Shield for AI Workflows
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/ciphyrs)](https://pypi.org/project/ciphyrs/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/ciphyrs)](https://pypi.org/project/ciphyrs/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
40
+
41
+ Intercept, mask, and restore PII before it reaches any LLM. Works with LangChain, CrewAI, or any Python application.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install ciphyrs # core SDK
47
+ pip install 'ciphyrs[langchain]' # + LangChain integration
48
+ pip install 'ciphyrs[crewai]' # + CrewAI integration
49
+ pip install 'ciphyrs[all]' # everything
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ```python
55
+ from ciphyrs import CiphyrsClient
56
+
57
+ client = CiphyrsClient(
58
+ api_key="cyp_live_...",
59
+ base_url="https://www.ciphyrs.com"
60
+ )
61
+
62
+ # Mask PII
63
+ result = client.mask("Contact Praveen at 9876125640 and praveen@acme.com")
64
+ print(result.masked_text)
65
+ # "Contact XXXX_a1b2c3 at XXXX_d4e5f6 and XXXX_g7h8i9"
66
+
67
+ # Restore PII
68
+ restored = client.restore(result.masked_text, result.session_id)
69
+ print(restored.restored_text)
70
+ # "Contact Praveen at 9876125640 and praveen@acme.com"
71
+ ```
72
+
73
+ ## Authentication
74
+
75
+ All API calls require an `x-api-key` header. Get your API key from the [Ciphyrs Dashboard](https://www.ciphyrs.com/dashboard).
76
+
77
+ 1. Register at [ciphyrs.com/register](https://www.ciphyrs.com/register)
78
+ 2. Go to **Settings > API Keys**
79
+ 3. Click **Generate API Key**
80
+ 4. Copy the key (starts with `cyp_live_`)
81
+
82
+ ## LangChain Integration
83
+
84
+ ### Option 1: Callback Handler (auto-masks all LLM calls)
85
+
86
+ ```python
87
+ from langchain_openai import ChatOpenAI
88
+ from ciphyrs.integrations.langchain import CiphyrsPIICallback
89
+
90
+ callback = CiphyrsPIICallback(api_key="cyp_live_...")
91
+ llm = ChatOpenAI(model="gpt-4o", callbacks=[callback])
92
+
93
+ # PII is automatically masked before reaching OpenAI
94
+ # and restored in the response
95
+ result = llm.invoke("Hi, I'm Praveen Kumar, my phone is 9876125640")
96
+ ```
97
+
98
+ ### Option 2: LCEL Runnables (pipe operator)
99
+
100
+ ```python
101
+ from ciphyrs.integrations.langchain import CiphyrsMaskRunnable, CiphyrsRestoreRunnable
102
+
103
+ session = {}
104
+ mask = CiphyrsMaskRunnable(api_key="cyp_live_...", session_store=session)
105
+ restore = CiphyrsRestoreRunnable(api_key="cyp_live_...", session_store=session)
106
+
107
+ # Build pipeline: mask -> prompt -> llm -> restore
108
+ chain = mask | prompt_template | llm | restore
109
+ result = chain.invoke({"input": "Call Praveen at 9876125640"})
110
+ ```
111
+
112
+ ### Option 3: Shield Wrapper (wrap any chain)
113
+
114
+ ```python
115
+ from ciphyrs.integrations.langchain import CiphyrsShield
116
+
117
+ shield = CiphyrsShield(api_key="cyp_live_...")
118
+ safe_chain = shield.wrap(my_existing_chain)
119
+ result = safe_chain.invoke({"input": "Praveen's email is praveen@acme.com"})
120
+ ```
121
+
122
+ ## Async Support
123
+
124
+ ```python
125
+ from ciphyrs import AsyncCiphyrsClient
126
+
127
+ async with AsyncCiphyrsClient(api_key="cyp_live_...") as client:
128
+ result = await client.mask("Contact Praveen at praveen@acme.com")
129
+ restored = await client.restore(result.masked_text, result.session_id)
130
+ ```
131
+
132
+ ## Detected Entity Types
133
+
134
+ | Entity | Example |
135
+ |--------|---------|
136
+ | PERSON | Praveen Kumar |
137
+ | EMAIL | praveen@acme.com |
138
+ | PHONE | 9876125640 |
139
+ | IN_AADHAAR | 2345 6789 0123 |
140
+ | IN_PAN | ABCDE1234F |
141
+ | CREDIT_CARD | 4111-1111-1111-1111 |
142
+ | IP_ADDRESS | 192.168.1.1 |
143
+ | DATE_OF_BIRTH | 15/03/1990 |
144
+ | LOCATION | Mumbai |
145
+ | ORGANIZATION | Acme Corp |
146
+ | API_KEY | sk-abc123... |
147
+
148
+ ## Links
149
+
150
+ - [Website](https://www.ciphyrs.com)
151
+ - [Documentation](https://www.ciphyrs.com/docs)
152
+ - [Dashboard](https://www.ciphyrs.com/dashboard)
@@ -0,0 +1,118 @@
1
+ # Ciphyrs — PII Shield for AI Workflows
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ciphyrs)](https://pypi.org/project/ciphyrs/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/ciphyrs)](https://pypi.org/project/ciphyrs/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Intercept, mask, and restore PII before it reaches any LLM. Works with LangChain, CrewAI, or any Python application.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install ciphyrs # core SDK
13
+ pip install 'ciphyrs[langchain]' # + LangChain integration
14
+ pip install 'ciphyrs[crewai]' # + CrewAI integration
15
+ pip install 'ciphyrs[all]' # everything
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from ciphyrs import CiphyrsClient
22
+
23
+ client = CiphyrsClient(
24
+ api_key="cyp_live_...",
25
+ base_url="https://www.ciphyrs.com"
26
+ )
27
+
28
+ # Mask PII
29
+ result = client.mask("Contact Praveen at 9876125640 and praveen@acme.com")
30
+ print(result.masked_text)
31
+ # "Contact XXXX_a1b2c3 at XXXX_d4e5f6 and XXXX_g7h8i9"
32
+
33
+ # Restore PII
34
+ restored = client.restore(result.masked_text, result.session_id)
35
+ print(restored.restored_text)
36
+ # "Contact Praveen at 9876125640 and praveen@acme.com"
37
+ ```
38
+
39
+ ## Authentication
40
+
41
+ All API calls require an `x-api-key` header. Get your API key from the [Ciphyrs Dashboard](https://www.ciphyrs.com/dashboard).
42
+
43
+ 1. Register at [ciphyrs.com/register](https://www.ciphyrs.com/register)
44
+ 2. Go to **Settings > API Keys**
45
+ 3. Click **Generate API Key**
46
+ 4. Copy the key (starts with `cyp_live_`)
47
+
48
+ ## LangChain Integration
49
+
50
+ ### Option 1: Callback Handler (auto-masks all LLM calls)
51
+
52
+ ```python
53
+ from langchain_openai import ChatOpenAI
54
+ from ciphyrs.integrations.langchain import CiphyrsPIICallback
55
+
56
+ callback = CiphyrsPIICallback(api_key="cyp_live_...")
57
+ llm = ChatOpenAI(model="gpt-4o", callbacks=[callback])
58
+
59
+ # PII is automatically masked before reaching OpenAI
60
+ # and restored in the response
61
+ result = llm.invoke("Hi, I'm Praveen Kumar, my phone is 9876125640")
62
+ ```
63
+
64
+ ### Option 2: LCEL Runnables (pipe operator)
65
+
66
+ ```python
67
+ from ciphyrs.integrations.langchain import CiphyrsMaskRunnable, CiphyrsRestoreRunnable
68
+
69
+ session = {}
70
+ mask = CiphyrsMaskRunnable(api_key="cyp_live_...", session_store=session)
71
+ restore = CiphyrsRestoreRunnable(api_key="cyp_live_...", session_store=session)
72
+
73
+ # Build pipeline: mask -> prompt -> llm -> restore
74
+ chain = mask | prompt_template | llm | restore
75
+ result = chain.invoke({"input": "Call Praveen at 9876125640"})
76
+ ```
77
+
78
+ ### Option 3: Shield Wrapper (wrap any chain)
79
+
80
+ ```python
81
+ from ciphyrs.integrations.langchain import CiphyrsShield
82
+
83
+ shield = CiphyrsShield(api_key="cyp_live_...")
84
+ safe_chain = shield.wrap(my_existing_chain)
85
+ result = safe_chain.invoke({"input": "Praveen's email is praveen@acme.com"})
86
+ ```
87
+
88
+ ## Async Support
89
+
90
+ ```python
91
+ from ciphyrs import AsyncCiphyrsClient
92
+
93
+ async with AsyncCiphyrsClient(api_key="cyp_live_...") as client:
94
+ result = await client.mask("Contact Praveen at praveen@acme.com")
95
+ restored = await client.restore(result.masked_text, result.session_id)
96
+ ```
97
+
98
+ ## Detected Entity Types
99
+
100
+ | Entity | Example |
101
+ |--------|---------|
102
+ | PERSON | Praveen Kumar |
103
+ | EMAIL | praveen@acme.com |
104
+ | PHONE | 9876125640 |
105
+ | IN_AADHAAR | 2345 6789 0123 |
106
+ | IN_PAN | ABCDE1234F |
107
+ | CREDIT_CARD | 4111-1111-1111-1111 |
108
+ | IP_ADDRESS | 192.168.1.1 |
109
+ | DATE_OF_BIRTH | 15/03/1990 |
110
+ | LOCATION | Mumbai |
111
+ | ORGANIZATION | Acme Corp |
112
+ | API_KEY | sk-abc123... |
113
+
114
+ ## Links
115
+
116
+ - [Website](https://www.ciphyrs.com)
117
+ - [Documentation](https://www.ciphyrs.com/docs)
118
+ - [Dashboard](https://www.ciphyrs.com/dashboard)
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ciphyrs"
7
+ version = "1.1.0"
8
+ description = "Python SDK for the Ciphyrs PII Shield API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Ciphyrs", email = "support@ciphyrs.com" },
14
+ ]
15
+ keywords = ["pii", "privacy", "masking", "ciphyrs"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Security",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = [
31
+ "httpx>=0.25.0",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ langchain = [
36
+ "langchain-core>=0.2.0",
37
+ ]
38
+ crewai = [
39
+ "crewai>=0.41.0",
40
+ ]
41
+ all = [
42
+ "langchain-core>=0.2.0",
43
+ "crewai>=0.41.0",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://www.ciphyrs.com"
48
+ Documentation = "https://www.ciphyrs.com/docs"
49
+ Repository = "https://github.com/praveen190/Ciphyrs"
50
+ Changelog = "https://github.com/praveen190/Ciphyrs/releases"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/ciphyrs"]
@@ -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"
@@ -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()
@@ -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
+ )