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.
- ciphyrs-1.1.0/.gitignore +16 -0
- ciphyrs-1.1.0/PKG-INFO +152 -0
- ciphyrs-1.1.0/README.md +118 -0
- ciphyrs-1.1.0/pyproject.toml +53 -0
- ciphyrs-1.1.0/src/ciphyrs/__init__.py +31 -0
- ciphyrs-1.1.0/src/ciphyrs/_http.py +183 -0
- ciphyrs-1.1.0/src/ciphyrs/_types.py +94 -0
- ciphyrs-1.1.0/src/ciphyrs/client.py +313 -0
- ciphyrs-1.1.0/src/ciphyrs/errors.py +107 -0
- ciphyrs-1.1.0/src/ciphyrs/integrations/__init__.py +1 -0
- ciphyrs-1.1.0/src/ciphyrs/integrations/crewai.py +465 -0
- ciphyrs-1.1.0/src/ciphyrs/integrations/langchain.py +572 -0
ciphyrs-1.1.0/.gitignore
ADDED
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
|
+
[](https://pypi.org/project/ciphyrs/)
|
|
38
|
+
[](https://pypi.org/project/ciphyrs/)
|
|
39
|
+
[](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)
|
ciphyrs-1.1.0/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Ciphyrs — PII Shield for AI Workflows
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/ciphyrs/)
|
|
4
|
+
[](https://pypi.org/project/ciphyrs/)
|
|
5
|
+
[](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
|
+
)
|