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.
@@ -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,5 @@
1
+ class KonfidantApiError(Exception):
2
+ def __init__(self, message: str, status_code: int, body: object) -> None:
3
+ super().__init__(message)
4
+ self.status_code = status_code
5
+ self.body = body
@@ -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"]