secryn 1.0.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.
secryn-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: secryn
3
+ Version: 1.0.0
4
+ Summary: Secryn Python SDK — manage secrets, projects, and API keys programmatically
5
+ Author: Secryn
6
+ License: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: requests>=2.28
9
+ Provides-Extra: dev
10
+ Requires-Dist: ruff>=0.11; extra == "dev"
11
+ Requires-Dist: mypy>=1.0; extra == "dev"
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: pytest-mock>=3.14; extra == "dev"
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "secryn"
3
+ version = "1.0.0"
4
+ description = "Secryn Python SDK — manage secrets, projects, and API keys programmatically"
5
+ license = { text = "Apache-2.0" }
6
+ authors = [{ name = "Secryn" }]
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "requests>=2.28",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ dev = [
14
+ "ruff>=0.11",
15
+ "mypy>=1.0",
16
+ "pytest>=8.0",
17
+ "pytest-mock>=3.14",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=68"]
22
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,7 @@
1
+ """Secryn Python SDK — manage secrets, projects, and API keys programmatically."""
2
+
3
+ from .client import SecrynClient
4
+ from .errors import SecrynApiError
5
+
6
+ __all__ = ["SecrynClient", "SecrynApiError"]
7
+ __version__ = "1.0.0"
@@ -0,0 +1,424 @@
1
+ """Secryn API client — full coverage of the Secryn REST API.
2
+
3
+ Supports both cookie-based authentication (after login) and API-key
4
+ authentication for programmatic access.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional
8
+ from urllib.parse import urljoin
9
+
10
+ import requests
11
+
12
+ from .errors import SecrynApiError
13
+
14
+
15
+ class _RequestMixin:
16
+ """Internal mixin that provides the low-level HTTP transport."""
17
+
18
+ def _url(self, path: str) -> str:
19
+ """Build an absolute URL by joining the base URL with a relative path.
20
+
21
+ Normalises the base URL to always end with a single ``/`` and strips
22
+ any leading ``/`` from the path so that :func:`urljoin` produces a
23
+ predictable result regardless of caller formatting.
24
+
25
+ Args:
26
+ path: Relative API path (e.g. ``/projects/1``).
27
+
28
+ Returns:
29
+ Fully-qualified URL string.
30
+ """
31
+ base = self.base_url.rstrip("/") + "/" # type: ignore[attr-defined]
32
+ return urljoin(base, path.lstrip("/"))
33
+
34
+ def _request(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ body: Optional[dict] = None,
39
+ raw: bool = False,
40
+ ) -> Any:
41
+ """Execute an HTTP request against the Secryn API and handle the response.
42
+
43
+ Decision order for response handling:
44
+ 1. If ``raw=True``, return the response body as plain text (still
45
+ raises on >=400).
46
+ 2. If the status is 204 No Content or the body is empty, return
47
+ ``None`` (but still raise on >=400 with a best-effort message).
48
+ 3. Attempt to parse the body as JSON; if parsing fails and the status
49
+ is >=400, raise with the raw text. On success with a non-JSON body,
50
+ return the plain text.
51
+ 4. If status is >=400 and JSON was parsed, raise
52
+ :exc:`SecrynApiError` using the structured error fields from the
53
+ API response (``message``, ``code``, ``details``).
54
+ 5. Otherwise return the parsed JSON data.
55
+
56
+ Args:
57
+ method: HTTP method (``GET``, ``POST``, ``PUT``, ``DELETE``).
58
+ path: Relative API path.
59
+ body: Optional JSON-serializable request body.
60
+ raw: If ``True``, return the response text directly instead of
61
+ attempting JSON parsing.
62
+
63
+ Returns:
64
+ Parsed JSON data, plain text (when ``raw``), or ``None`` for
65
+ empty/204 responses.
66
+
67
+ Raises:
68
+ SecrynApiError: When the API responds with a status >=400.
69
+ """
70
+ url = self._url(path)
71
+ kwargs: dict = {}
72
+ if body is not None:
73
+ kwargs["json"] = body
74
+
75
+ resp = self.session.request(method, url, **kwargs) # type: ignore[attr-defined]
76
+
77
+ if raw:
78
+ if resp.status_code >= 400:
79
+ self._raise_error(resp)
80
+ return resp.text
81
+
82
+ if resp.status_code == 204 or not resp.text.strip():
83
+ if resp.status_code >= 400:
84
+ raise SecrynApiError("Request failed", resp.status_code, str(resp.status_code))
85
+ return None
86
+
87
+ try:
88
+ data = resp.json()
89
+ except ValueError:
90
+ if resp.status_code >= 400:
91
+ raise SecrynApiError(resp.text, resp.status_code)
92
+ return resp.text
93
+
94
+ if resp.status_code >= 400:
95
+ raise SecrynApiError(
96
+ data.get("message", "Request failed"),
97
+ resp.status_code,
98
+ data.get("code", ""),
99
+ data.get("details"),
100
+ )
101
+
102
+ return data
103
+
104
+ def _raise_error(self, resp: requests.Response) -> None:
105
+ """Extract structured error information from a response and raise.
106
+
107
+ Attempts to parse the response body as JSON first; falls back to the
108
+ raw response text if parsing fails.
109
+
110
+ Args:
111
+ resp: The :class:`requests.Response` object with status >=400.
112
+
113
+ Raises:
114
+ SecrynApiError: Always — this is a terminal handler for error
115
+ responses.
116
+ """
117
+ try:
118
+ data = resp.json()
119
+ raise SecrynApiError(
120
+ data.get("message", resp.text),
121
+ resp.status_code,
122
+ data.get("code", ""),
123
+ data.get("details"),
124
+ )
125
+ except (ValueError, KeyError):
126
+ raise SecrynApiError(resp.text, resp.status_code)
127
+
128
+ def _get(self, path: str) -> Any:
129
+ return self._request("GET", path)
130
+
131
+ def _post(self, path: str, body: Optional[dict] = None) -> Any:
132
+ return self._request("POST", path, body)
133
+
134
+ def _put(self, path: str, body: Optional[dict] = None) -> Any:
135
+ return self._request("PUT", path, body)
136
+
137
+ def _delete(self, path: str) -> Any:
138
+ return self._request("DELETE", path)
139
+
140
+
141
+ class _AuthProxy:
142
+ """Proxy providing auth-related API methods."""
143
+
144
+ def __init__(self, client: "SecrynClient") -> None:
145
+ self._client = client
146
+
147
+ def login(self, email: str, password: str) -> Any:
148
+ return self._client._post("/auth/login", {"email": email, "password": password})
149
+
150
+ def register(self, email: str, password: str, username: Optional[str] = None) -> Any:
151
+ body: dict = {"email": email, "password": password}
152
+ if username:
153
+ body["username"] = username
154
+ return self._client._post("/auth/register", body)
155
+
156
+ def logout(self) -> None:
157
+ """Send a logout request and unconditionally clear the local session.
158
+
159
+ The cookie jar is cleared inside a ``finally`` block so that even if
160
+ the server returns an error the local session is still discarded.
161
+ """
162
+ try:
163
+ self._client._post("/auth/logout")
164
+ finally:
165
+ self._client.session.cookies.clear()
166
+
167
+ def refresh(self) -> None:
168
+ self._client._post("/auth/refresh")
169
+
170
+ def forgot_password(self, email: str) -> Any:
171
+ return self._client._post("/auth/forgot-password", {"email": email})
172
+
173
+ def reset_password(self, token: str, password: str) -> Any:
174
+ return self._client._post("/auth/reset-password", {"token": token, "password": password})
175
+
176
+
177
+ class _MFAProxy:
178
+ """Proxy providing MFA-related API methods."""
179
+
180
+ def __init__(self, client: "SecrynClient") -> None:
181
+ self._client = client
182
+
183
+ def setup(self) -> Any:
184
+ return self._client._get("/auth/mfa/setup")
185
+
186
+ def enable(self, token: str) -> Any:
187
+ return self._client._post("/auth/mfa/enable", {"token": token})
188
+
189
+ def disable(self) -> Any:
190
+ return self._client._post("/auth/mfa/disable")
191
+
192
+ def confirm(self, token: str, mfa_token: str) -> Any:
193
+ return self._client._post("/auth/mfa/confirm", {"token": token, "mfaToken": mfa_token})
194
+
195
+ def recovery(self, code: str, mfa_token: str) -> Any:
196
+ return self._client._post("/auth/mfa/recovery", {"code": code, "mfaToken": mfa_token})
197
+
198
+ def recovery_codes(self) -> Any:
199
+ return self._client._get("/auth/mfa/recovery-codes")
200
+
201
+ def regenerate_codes(self) -> Any:
202
+ return self._client._post("/auth/mfa/recovery-codes/regenerate")
203
+
204
+ def send_backup_code(self, email: str) -> Any:
205
+ return self._client._post("/auth/mfa/send-backup-code", {"email": email})
206
+
207
+ def status(self) -> Any:
208
+ return self._client._get("/auth/mfa/status")
209
+
210
+
211
+ class _UsersProxy:
212
+ """Proxy providing user-related API methods."""
213
+
214
+ def __init__(self, client: "SecrynClient") -> None:
215
+ self._client = client
216
+
217
+ def me(self) -> Any:
218
+ return self._client._get("/users/@me")
219
+
220
+ def get(self, user_id: str) -> Any:
221
+ return self._client._get(f"/users/{user_id}")
222
+
223
+ def update(self, **kwargs: Any) -> Any:
224
+ return self._client._put("/users", kwargs)
225
+
226
+ def delete(self) -> None:
227
+ self._client._delete("/users")
228
+
229
+
230
+ class _ApiKeysProxy:
231
+ """Proxy providing API-key-related API methods."""
232
+
233
+ def __init__(self, client: "SecrynClient") -> None:
234
+ self._client = client
235
+
236
+ def create(self, name: str, permissions: Optional[List[str]] = None) -> Any:
237
+ body: dict = {"name": name}
238
+ if permissions:
239
+ body["permissions"] = permissions
240
+ return self._client._post("/api-keys", body)
241
+
242
+ def list(self) -> Any:
243
+ return self._client._get("/api-keys/@all-user")
244
+
245
+ def get(self, key_id: str) -> Any:
246
+ return self._client._get(f"/api-keys/{key_id}")
247
+
248
+ def update(self, key_id: str, **kwargs: Any) -> Any:
249
+ return self._client._put(f"/api-keys/{key_id}", kwargs)
250
+
251
+ def delete(self, key_id: str) -> None:
252
+ self._client._delete(f"/api-keys/{key_id}")
253
+
254
+
255
+ class _ProjectsProxy:
256
+ """Proxy providing project-related API methods."""
257
+
258
+ def __init__(self, client: "SecrynClient") -> None:
259
+ self._client = client
260
+
261
+ def create(self, name: str, description: Optional[str] = None) -> Any:
262
+ body: dict = {"name": name}
263
+ if description:
264
+ body["description"] = description
265
+ return self._client._post("/projects", body)
266
+
267
+ def list(self) -> Any:
268
+ return self._client._get("/projects/@all")
269
+
270
+ def get(self, project_id: str) -> Any:
271
+ return self._client._get(f"/projects/{project_id}")
272
+
273
+ def update(self, project_id: str, **kwargs: Any) -> Any:
274
+ return self._client._put(f"/projects/{project_id}", kwargs)
275
+
276
+ def delete(self, project_id: str) -> None:
277
+ self._client._delete(f"/projects/{project_id}")
278
+
279
+ def transfer(self, project_id: str, new_owner_id: str) -> Any:
280
+ return self._client._post(
281
+ f"/projects/{project_id}/transfer",
282
+ {"newOwnerId": new_owner_id},
283
+ )
284
+
285
+
286
+ class _InvitesProxy:
287
+ """Proxy providing project-invitation API methods."""
288
+
289
+ def __init__(self, client: "SecrynClient") -> None:
290
+ self._client = client
291
+
292
+ def create(self, project_id: str, email: Optional[str] = None) -> Any:
293
+ body: dict = {}
294
+ if email:
295
+ body["email"] = email
296
+ # body or None: send None (no body) when no email is provided,
297
+ # so the server creates an open invite that anyone can accept.
298
+ return self._client._post(f"/projects/{project_id}/invites", body or None)
299
+
300
+ def accept(self, slug: str) -> Any:
301
+ """Accept a project invitation by its slug.
302
+
303
+ Uses ``GET`` instead of ``POST`` because the server identifies the
304
+ invite via a unique URL slug and does not require a request body.
305
+ """
306
+ return self._client._get(f"/projects/invites/{slug}")
307
+
308
+
309
+ class _MembersProxy:
310
+ """Proxy providing project-member API methods."""
311
+
312
+ def __init__(self, client: "SecrynClient") -> None:
313
+ self._client = client
314
+
315
+ def remove(self, project_id: str, member_id: str) -> None:
316
+ self._client._delete(f"/projects/{project_id}/members/{member_id}")
317
+
318
+ def add_permissions(self, project_id: str, member_id: str, permissions: List[str]) -> Any:
319
+ return self._client._post(
320
+ f"/projects/{project_id}/members/{member_id}/permissions",
321
+ {"permissions": permissions},
322
+ )
323
+
324
+ def remove_permissions(self, project_id: str, member_id: str, permissions: List[str]) -> Any:
325
+ # Uses raw _request instead of _delete because the DELETE endpoint
326
+ # accepts a JSON body listing the permissions to remove.
327
+ self._client._request(
328
+ "DELETE",
329
+ f"/projects/{project_id}/members/{member_id}/permissions",
330
+ {"permissions": permissions},
331
+ )
332
+
333
+
334
+ class _SecretsProxy:
335
+ """Proxy providing secret-related API methods."""
336
+
337
+ def __init__(self, client: "SecrynClient") -> None:
338
+ self._client = client
339
+
340
+ def create(
341
+ self,
342
+ project_id: str,
343
+ name: str,
344
+ value: str,
345
+ notes: Optional[str] = None,
346
+ ) -> Any:
347
+ body: dict = {"name": name, "value": value}
348
+ if notes:
349
+ body["notes"] = notes
350
+ return self._client._post(f"/projects/{project_id}/secrets", body)
351
+
352
+ def get(self, secret_id: str) -> Any:
353
+ return self._client._get(f"/projects/secrets/{secret_id}")
354
+
355
+ def list(self, project_id: str) -> Any:
356
+ return self._client._get(f"/projects/{project_id}/secrets")
357
+
358
+ def update(self, secret_id: str, **kwargs: Any) -> Any:
359
+ return self._client._put(f"/projects/secrets/{secret_id}", kwargs)
360
+
361
+ def delete(self, secret_id: str) -> None:
362
+ self._client._delete(f"/projects/secrets/{secret_id}")
363
+
364
+ def export_dotenv(self, project_id: str) -> str:
365
+ """Export project secrets as a dotenv-formatted string.
366
+
367
+ Uses raw mode to prevent JSON parsing of the ``.env`` payload.
368
+ Falls back to an empty string when the response body is ``None``
369
+ (e.g. a 204 No Content from an empty project).
370
+ """
371
+ result = self._client._request("GET", f"/projects/{project_id}/secrets/export", raw=True)
372
+ return result if result is not None else ""
373
+
374
+
375
+ class SecrynClient(_RequestMixin):
376
+ """HTTP client for the full Secryn REST API.
377
+
378
+ Supports both cookie-based authentication (via ``auth.login``) and
379
+ API-key authentication (pass ``api_key`` to the constructor).
380
+
381
+ Args:
382
+ base_url: Base URL of the Secryn API including the ``/api/v1`` prefix.
383
+ api_key: Optional API key for programmatic access.
384
+
385
+ Example:
386
+
387
+ # Cookie-based
388
+ client = SecrynClient()
389
+ client.auth.login("user@example.com", "password")
390
+ secrets = client.secrets.list("project-id")
391
+
392
+ # API-key-based
393
+ client = SecrynClient(api_key="sk-...")
394
+ secret = client.secrets.get("secret-id")
395
+ """
396
+
397
+ def __init__(
398
+ self,
399
+ base_url: str = "http://localhost:3000/api/v1",
400
+ api_key: Optional[str] = None,
401
+ user_agent: str = "secryn-sdk-python/1.0.0",
402
+ ) -> None:
403
+ self.base_url = base_url
404
+ self.api_key = api_key
405
+ self.session = requests.Session()
406
+ self.session.headers.update(
407
+ {
408
+ "Content-Type": "application/json",
409
+ "Accept": "application/json",
410
+ "User-Agent": user_agent,
411
+ }
412
+ )
413
+ if api_key:
414
+ self.session.headers["api-key"] = api_key
415
+
416
+ # Proxies for API resource groups
417
+ self.auth = _AuthProxy(self)
418
+ self.mfa = _MFAProxy(self)
419
+ self.users = _UsersProxy(self)
420
+ self.api_keys = _ApiKeysProxy(self)
421
+ self.projects = _ProjectsProxy(self)
422
+ self.invites = _InvitesProxy(self)
423
+ self.members = _MembersProxy(self)
424
+ self.secrets = _SecretsProxy(self)
@@ -0,0 +1,32 @@
1
+ """Error types for the Secryn Python SDK."""
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class SecrynApiError(Exception):
7
+ """Raised when the Secryn API returns a non-2xx status code.
8
+
9
+ Attributes:
10
+ status_code: HTTP status code returned by the server.
11
+ message: Human-readable error description.
12
+ code: Machine-readable error identifier.
13
+ details: Optional structured context (e.g. validation errors).
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ status_code: int,
20
+ code: str = "UNKNOWN",
21
+ details: Any = None,
22
+ ) -> None:
23
+ self.status_code = status_code
24
+ self.message = message
25
+ self.code = code
26
+ self.details = details
27
+ super().__init__(message)
28
+
29
+ def __str__(self) -> str:
30
+ if self.details:
31
+ return f"{self.message} ({self.code}): {self.details}"
32
+ return f"{self.message} ({self.code})"
File without changes