konfidant 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.
konfidant-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: konfidant
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Konfidant API
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11,<4.0
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: httpx (>=0.27,<0.28)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .client import KonfidantClient
|
|
2
|
+
from .errors import KonfidantApiError
|
|
3
|
+
from .types import (
|
|
4
|
+
FileMetadataHeaders,
|
|
5
|
+
FileStatusResponse,
|
|
6
|
+
ListSharesResponse,
|
|
7
|
+
Pagination,
|
|
8
|
+
Share,
|
|
9
|
+
ShareFileResponse,
|
|
10
|
+
ShareResult,
|
|
11
|
+
ShareTextResponse,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"KonfidantClient",
|
|
16
|
+
"KonfidantApiError",
|
|
17
|
+
"FileMetadataHeaders",
|
|
18
|
+
"FileStatusResponse",
|
|
19
|
+
"ListSharesResponse",
|
|
20
|
+
"Pagination",
|
|
21
|
+
"Share",
|
|
22
|
+
"ShareFileResponse",
|
|
23
|
+
"ShareResult",
|
|
24
|
+
"ShareTextResponse",
|
|
25
|
+
]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import IO, Any, Optional
|
|
3
|
+
from urllib.parse import quote, urlencode
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .errors import KonfidantApiError
|
|
8
|
+
from .types import (
|
|
9
|
+
FileMetadataHeaders,
|
|
10
|
+
FileStatusResponse,
|
|
11
|
+
ListSharesResponse,
|
|
12
|
+
Pagination,
|
|
13
|
+
Share,
|
|
14
|
+
ShareFileResponse,
|
|
15
|
+
ShareResult,
|
|
16
|
+
ShareTextResponse,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://www.konfidant.app"
|
|
20
|
+
DEFAULT_TIMEOUT = 120.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class KonfidantClient:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
api_key: str,
|
|
27
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
28
|
+
timeout: Optional[float] = DEFAULT_TIMEOUT,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not api_key:
|
|
31
|
+
raise ValueError("api_key is required")
|
|
32
|
+
self._api_key = api_key
|
|
33
|
+
self._base_url = base_url.rstrip("/")
|
|
34
|
+
self._http = httpx.Client(timeout=httpx.Timeout(timeout))
|
|
35
|
+
|
|
36
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
37
|
+
return {
|
|
38
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
43
|
+
url = self._base_url + path
|
|
44
|
+
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
|
|
45
|
+
response = self._http.request(method, url, headers=headers, **kwargs)
|
|
46
|
+
|
|
47
|
+
content_type = response.headers.get("content-type", "")
|
|
48
|
+
if "application/json" in content_type:
|
|
49
|
+
body: Any = response.json()
|
|
50
|
+
else:
|
|
51
|
+
body = response.text
|
|
52
|
+
|
|
53
|
+
if not (200 <= response.status_code < 300):
|
|
54
|
+
message = (
|
|
55
|
+
body.get("error")
|
|
56
|
+
if isinstance(body, dict) and "error" in body
|
|
57
|
+
else f"HTTP {response.status_code}"
|
|
58
|
+
)
|
|
59
|
+
raise KonfidantApiError(message, response.status_code, body)
|
|
60
|
+
|
|
61
|
+
return body
|
|
62
|
+
|
|
63
|
+
def share_text(self, text: str, ttl_hours: int) -> ShareTextResponse:
|
|
64
|
+
body = self._request("POST", "/api/v1/texts", json={"text": text, "ttl_hours": ttl_hours})
|
|
65
|
+
return ShareTextResponse(
|
|
66
|
+
text_id=body["text_id"],
|
|
67
|
+
share_url=body["share_url"],
|
|
68
|
+
expires_at=body["expires_at"],
|
|
69
|
+
verified_burn=body["verified_burn"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def share_file(self, filename: str, file_size: int, ttl_hours: int) -> ShareFileResponse:
|
|
73
|
+
body = self._request(
|
|
74
|
+
"POST",
|
|
75
|
+
"/api/v1/files",
|
|
76
|
+
json={"filename": filename, "file_size": file_size, "ttl_hours": ttl_hours},
|
|
77
|
+
)
|
|
78
|
+
h = body["metadata_headers"]
|
|
79
|
+
return ShareFileResponse(
|
|
80
|
+
upload_url=body["upload_url"],
|
|
81
|
+
file_key=body["file_key"],
|
|
82
|
+
metadata_headers=FileMetadataHeaders(
|
|
83
|
+
user_id=h["x-amz-meta-user-id"],
|
|
84
|
+
ttl_hours=h["x-amz-meta-ttl-hours"],
|
|
85
|
+
organization_id=h["x-amz-meta-organization-id"],
|
|
86
|
+
),
|
|
87
|
+
poll_url=body["poll_url"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def get_file_status(self, file_key: str) -> FileStatusResponse:
|
|
91
|
+
encoded = quote(file_key, safe="")
|
|
92
|
+
body = self._request("GET", f"/api/v1/files/{encoded}/status")
|
|
93
|
+
return FileStatusResponse(
|
|
94
|
+
status=body["status"],
|
|
95
|
+
message=body.get("message"),
|
|
96
|
+
file_id=body.get("file_id"),
|
|
97
|
+
file_name=body.get("file_name"),
|
|
98
|
+
share_url=body.get("share_url"),
|
|
99
|
+
expires_at=body.get("expires_at"),
|
|
100
|
+
verified_burn=body.get("verified_burn"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def list_shares(
|
|
104
|
+
self,
|
|
105
|
+
type: Optional[str] = None,
|
|
106
|
+
status: Optional[str] = None,
|
|
107
|
+
limit: Optional[int] = None,
|
|
108
|
+
offset: Optional[int] = None,
|
|
109
|
+
) -> ListSharesResponse:
|
|
110
|
+
params: dict[str, str] = {}
|
|
111
|
+
if type is not None:
|
|
112
|
+
params["type"] = type
|
|
113
|
+
if status is not None:
|
|
114
|
+
params["status"] = status
|
|
115
|
+
if limit is not None:
|
|
116
|
+
params["limit"] = str(limit)
|
|
117
|
+
if offset is not None:
|
|
118
|
+
params["offset"] = str(offset)
|
|
119
|
+
|
|
120
|
+
path = "/api/v1/shares"
|
|
121
|
+
if params:
|
|
122
|
+
path += "?" + urlencode(params)
|
|
123
|
+
|
|
124
|
+
body = self._request("GET", path)
|
|
125
|
+
return ListSharesResponse(
|
|
126
|
+
shares=[
|
|
127
|
+
Share(
|
|
128
|
+
type=s["type"],
|
|
129
|
+
file_name=s["file_name"],
|
|
130
|
+
file_size_bytes=s["file_size_bytes"],
|
|
131
|
+
created_at=s["created_at"],
|
|
132
|
+
expires_at=s["expires_at"],
|
|
133
|
+
accessed_at=s.get("accessed_at"),
|
|
134
|
+
created_by=s["created_by"],
|
|
135
|
+
)
|
|
136
|
+
for s in body["shares"]
|
|
137
|
+
],
|
|
138
|
+
pagination=Pagination(
|
|
139
|
+
total=body["pagination"]["total"],
|
|
140
|
+
limit=body["pagination"]["limit"],
|
|
141
|
+
offset=body["pagination"]["offset"],
|
|
142
|
+
has_more=body["pagination"]["has_more"],
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def upload_file(
|
|
147
|
+
self,
|
|
148
|
+
data: bytes | IO[bytes],
|
|
149
|
+
size: int,
|
|
150
|
+
content_type: str,
|
|
151
|
+
presigned: ShareFileResponse,
|
|
152
|
+
) -> None:
|
|
153
|
+
headers = {
|
|
154
|
+
"Content-Type": content_type,
|
|
155
|
+
"Content-Length": str(size),
|
|
156
|
+
"x-amz-meta-organization-id": presigned.metadata_headers.organization_id,
|
|
157
|
+
"x-amz-meta-ttl-hours": presigned.metadata_headers.ttl_hours,
|
|
158
|
+
"x-amz-meta-user-id": presigned.metadata_headers.user_id,
|
|
159
|
+
}
|
|
160
|
+
response = self._http.put(presigned.upload_url, content=data, headers=headers)
|
|
161
|
+
if not (200 <= response.status_code < 300):
|
|
162
|
+
raise KonfidantApiError(
|
|
163
|
+
f"file upload failed: HTTP {response.status_code}",
|
|
164
|
+
response.status_code,
|
|
165
|
+
response.text,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def share_and_upload_file(
|
|
169
|
+
self,
|
|
170
|
+
data: bytes | IO[bytes],
|
|
171
|
+
size: int,
|
|
172
|
+
filename: str,
|
|
173
|
+
content_type: str,
|
|
174
|
+
ttl_hours: int,
|
|
175
|
+
poll_interval: float = 2.0,
|
|
176
|
+
timeout: float = 60.0,
|
|
177
|
+
) -> ShareResult:
|
|
178
|
+
presigned = self.share_file(filename=filename, file_size=size, ttl_hours=ttl_hours)
|
|
179
|
+
self.upload_file(data=data, size=size, content_type=content_type, presigned=presigned)
|
|
180
|
+
|
|
181
|
+
deadline = time.monotonic() + timeout
|
|
182
|
+
while time.monotonic() < deadline:
|
|
183
|
+
file_status = self.get_file_status(presigned.file_key)
|
|
184
|
+
if file_status.status == "complete":
|
|
185
|
+
return ShareResult(
|
|
186
|
+
share_url=file_status.share_url,
|
|
187
|
+
file_id=file_status.file_id,
|
|
188
|
+
expires_at=file_status.expires_at,
|
|
189
|
+
verified_burn=file_status.verified_burn,
|
|
190
|
+
)
|
|
191
|
+
time.sleep(poll_interval)
|
|
192
|
+
|
|
193
|
+
raise TimeoutError(f"konfidant: encryption timed out after {timeout}s")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class FileMetadataHeaders:
|
|
7
|
+
user_id: str
|
|
8
|
+
ttl_hours: str
|
|
9
|
+
organization_id: str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ShareTextResponse:
|
|
14
|
+
text_id: str
|
|
15
|
+
share_url: str
|
|
16
|
+
expires_at: str
|
|
17
|
+
verified_burn: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ShareFileResponse:
|
|
22
|
+
upload_url: str
|
|
23
|
+
file_key: str
|
|
24
|
+
metadata_headers: FileMetadataHeaders
|
|
25
|
+
poll_url: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FileStatusResponse:
|
|
30
|
+
status: str # "processing" or "complete"
|
|
31
|
+
message: Optional[str] = None
|
|
32
|
+
file_id: Optional[str] = None
|
|
33
|
+
file_name: Optional[str] = None
|
|
34
|
+
share_url: Optional[str] = None
|
|
35
|
+
expires_at: Optional[str] = None
|
|
36
|
+
verified_burn: Optional[bool] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Share:
|
|
41
|
+
type: str
|
|
42
|
+
file_name: str
|
|
43
|
+
file_size_bytes: int
|
|
44
|
+
created_at: str
|
|
45
|
+
expires_at: str
|
|
46
|
+
accessed_at: Optional[str]
|
|
47
|
+
created_by: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Pagination:
|
|
52
|
+
total: int
|
|
53
|
+
limit: int
|
|
54
|
+
offset: int
|
|
55
|
+
has_more: bool
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ListSharesResponse:
|
|
60
|
+
shares: list[Share]
|
|
61
|
+
pagination: Pagination
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ShareResult:
|
|
66
|
+
share_url: str
|
|
67
|
+
file_id: str
|
|
68
|
+
expires_at: str
|
|
69
|
+
verified_burn: bool
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "konfidant"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for the Konfidant API"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
packages = [{ include = "konfidant" }]
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = "^3.11"
|
|
10
|
+
httpx = "^0.27"
|
|
11
|
+
|
|
12
|
+
[tool.poetry.group.dev.dependencies]
|
|
13
|
+
pytest = "^8.0"
|
|
14
|
+
respx = "^0.21"
|
|
15
|
+
pytest-cov = "^5.0"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|