documentors 0.1.0__py3-none-any.whl
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.
- documentors/__init__.py +59 -0
- documentors/_transport.py +173 -0
- documentors/admin.py +250 -0
- documentors/client.py +98 -0
- documentors/compliance.py +137 -0
- documentors/documents.py +118 -0
- documentors/exceptions.py +55 -0
- documentors/models.py +224 -0
- documentors/security.py +132 -0
- documentors-0.1.0.dist-info/METADATA +145 -0
- documentors-0.1.0.dist-info/RECORD +13 -0
- documentors-0.1.0.dist-info/WHEEL +4 -0
- documentors-0.1.0.dist-info/licenses/LICENSE +21 -0
documentors/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""DocuMentors Python SDK — secure document lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from .client import DocumentorsClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
DocumentorsError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ServerError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from .models import (
|
|
15
|
+
APIKey,
|
|
16
|
+
APIKeyCreated,
|
|
17
|
+
Document,
|
|
18
|
+
DocumentVersion,
|
|
19
|
+
LegalHold,
|
|
20
|
+
PaginatedResponse,
|
|
21
|
+
PhishingVerdict,
|
|
22
|
+
PIIFinding,
|
|
23
|
+
RetentionPolicy,
|
|
24
|
+
ScanHistoryEntry,
|
|
25
|
+
ScanResult,
|
|
26
|
+
TokenResponse,
|
|
27
|
+
URLThreat,
|
|
28
|
+
User,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DocumentorsClient",
|
|
33
|
+
# Exceptions
|
|
34
|
+
"DocumentorsError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"PermissionDeniedError",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
"ValidationError",
|
|
39
|
+
"RateLimitError",
|
|
40
|
+
"ServerError",
|
|
41
|
+
"ConflictError",
|
|
42
|
+
# Models
|
|
43
|
+
"Document",
|
|
44
|
+
"DocumentVersion",
|
|
45
|
+
"PaginatedResponse",
|
|
46
|
+
"ScanResult",
|
|
47
|
+
"PIIFinding",
|
|
48
|
+
"ScanHistoryEntry",
|
|
49
|
+
"PhishingVerdict",
|
|
50
|
+
"URLThreat",
|
|
51
|
+
"LegalHold",
|
|
52
|
+
"RetentionPolicy",
|
|
53
|
+
"User",
|
|
54
|
+
"APIKey",
|
|
55
|
+
"APIKeyCreated",
|
|
56
|
+
"TokenResponse",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""HTTP transport with retry, error mapping, and auth injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
DocumentorsError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
PermissionDeniedError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServerError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("documentors")
|
|
23
|
+
|
|
24
|
+
_STATUS_MAP: dict[int, type[DocumentorsError]] = {
|
|
25
|
+
401: AuthenticationError,
|
|
26
|
+
403: PermissionDeniedError,
|
|
27
|
+
404: NotFoundError,
|
|
28
|
+
409: ConflictError,
|
|
29
|
+
422: ValidationError,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
33
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
34
|
+
_DEFAULT_BACKOFF_FACTOR = 0.5
|
|
35
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Transport:
|
|
39
|
+
"""Low-level HTTP helper used by resource modules."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str,
|
|
45
|
+
headers: dict[str, str],
|
|
46
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
47
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
48
|
+
backoff_factor: float = _DEFAULT_BACKOFF_FACTOR,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._base_url = base_url.rstrip("/")
|
|
51
|
+
self._headers = headers
|
|
52
|
+
self._timeout = timeout
|
|
53
|
+
self._max_retries = max_retries
|
|
54
|
+
self._backoff_factor = backoff_factor
|
|
55
|
+
self._client = httpx.Client(
|
|
56
|
+
base_url=self._base_url,
|
|
57
|
+
headers=self._headers,
|
|
58
|
+
timeout=self._timeout,
|
|
59
|
+
follow_redirects=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# -- public helpers -----------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def request(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
*,
|
|
69
|
+
json: Optional[dict[str, Any]] = None,
|
|
70
|
+
params: Optional[dict[str, Any]] = None,
|
|
71
|
+
data: Optional[dict[str, Any]] = None,
|
|
72
|
+
files: Optional[Any] = None,
|
|
73
|
+
extra_headers: Optional[dict[str, str]] = None,
|
|
74
|
+
) -> httpx.Response:
|
|
75
|
+
"""Send an HTTP request with retry and error mapping."""
|
|
76
|
+
url = path if path.startswith("http") else path
|
|
77
|
+
headers = {**self._headers, **(extra_headers or {})}
|
|
78
|
+
|
|
79
|
+
last_exc: Optional[Exception] = None
|
|
80
|
+
for attempt in range(1, self._max_retries + 1):
|
|
81
|
+
try:
|
|
82
|
+
resp = self._client.request(
|
|
83
|
+
method,
|
|
84
|
+
url,
|
|
85
|
+
json=json,
|
|
86
|
+
params=_strip_none(params),
|
|
87
|
+
data=data,
|
|
88
|
+
files=files,
|
|
89
|
+
headers=headers,
|
|
90
|
+
)
|
|
91
|
+
except httpx.TransportError as exc:
|
|
92
|
+
last_exc = exc
|
|
93
|
+
if attempt < self._max_retries:
|
|
94
|
+
self._sleep(attempt)
|
|
95
|
+
continue
|
|
96
|
+
raise DocumentorsError(f"Connection error: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
if resp.status_code < 400:
|
|
99
|
+
return resp
|
|
100
|
+
|
|
101
|
+
if resp.status_code in _RETRYABLE_STATUS and attempt < self._max_retries:
|
|
102
|
+
retry_after = _parse_retry_after(resp)
|
|
103
|
+
self._sleep(attempt, retry_after)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
self._raise_for_status(resp)
|
|
107
|
+
|
|
108
|
+
raise DocumentorsError(f"Request failed after {self._max_retries} retries") from last_exc
|
|
109
|
+
|
|
110
|
+
def get(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
111
|
+
return self.request("GET", path, **kwargs)
|
|
112
|
+
|
|
113
|
+
def post(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
114
|
+
return self.request("POST", path, **kwargs)
|
|
115
|
+
|
|
116
|
+
def put(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
117
|
+
return self.request("PUT", path, **kwargs)
|
|
118
|
+
|
|
119
|
+
def patch(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
120
|
+
return self.request("PATCH", path, **kwargs)
|
|
121
|
+
|
|
122
|
+
def delete(self, path: str, **kwargs: Any) -> httpx.Response:
|
|
123
|
+
return self.request("DELETE", path, **kwargs)
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
self._client.close()
|
|
127
|
+
|
|
128
|
+
# -- internals ----------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def _sleep(self, attempt: int, override: Optional[float] = None) -> None:
|
|
131
|
+
delay = override if override is not None else self._backoff_factor * (2 ** (attempt - 1))
|
|
132
|
+
logger.debug("Retry attempt %d — sleeping %.1fs", attempt, delay)
|
|
133
|
+
time.sleep(delay)
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
137
|
+
detail = ""
|
|
138
|
+
details: dict[str, Any] = {}
|
|
139
|
+
try:
|
|
140
|
+
body = resp.json()
|
|
141
|
+
detail = body.get("detail", body.get("message", ""))
|
|
142
|
+
details = body if isinstance(body, dict) else {}
|
|
143
|
+
except Exception:
|
|
144
|
+
detail = resp.text[:500]
|
|
145
|
+
|
|
146
|
+
if resp.status_code == 429:
|
|
147
|
+
raise RateLimitError(
|
|
148
|
+
detail or "Rate limit exceeded",
|
|
149
|
+
retry_after=_parse_retry_after(resp),
|
|
150
|
+
details=details,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
exc_cls = _STATUS_MAP.get(resp.status_code, ServerError if resp.status_code >= 500 else DocumentorsError)
|
|
154
|
+
raise exc_cls(
|
|
155
|
+
detail or f"HTTP {resp.status_code}",
|
|
156
|
+
details=details,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _strip_none(params: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
|
161
|
+
if params is None:
|
|
162
|
+
return None
|
|
163
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_retry_after(resp: httpx.Response) -> Optional[int]:
|
|
167
|
+
val = resp.headers.get("Retry-After")
|
|
168
|
+
if val is None:
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
return int(val)
|
|
172
|
+
except ValueError:
|
|
173
|
+
return None
|
documentors/admin.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Admin operations — users, organizations, API keys, audit logs, devices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from ._transport import Transport
|
|
9
|
+
from .models import ActivationCode, APIKey, APIKeyCreated, Device, OrgInvitation, OrgMember, PaginatedResponse, User
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Admin:
|
|
13
|
+
"""``client.admin.*`` — administrative endpoints (requires admin/superuser role)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, transport: Transport) -> None:
|
|
16
|
+
self._t = transport
|
|
17
|
+
|
|
18
|
+
# -- Users --------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def list_users(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
is_active: Optional[bool] = None,
|
|
24
|
+
search: Optional[str] = None,
|
|
25
|
+
skip: int = 0,
|
|
26
|
+
limit: int = 50,
|
|
27
|
+
) -> PaginatedResponse:
|
|
28
|
+
"""List platform users with optional filters."""
|
|
29
|
+
resp = self._t.get(
|
|
30
|
+
"/api/v1/users",
|
|
31
|
+
params={"is_active": is_active, "search": search, "skip": skip, "limit": limit},
|
|
32
|
+
)
|
|
33
|
+
return PaginatedResponse.model_validate(resp.json())
|
|
34
|
+
|
|
35
|
+
def get_user(self, user_id: UUID | str) -> User:
|
|
36
|
+
"""Get a user by ID."""
|
|
37
|
+
resp = self._t.get(f"/api/v1/users/{user_id}")
|
|
38
|
+
return User.model_validate(resp.json())
|
|
39
|
+
|
|
40
|
+
def disable_user(self, user_id: UUID | str) -> dict[str, Any]:
|
|
41
|
+
"""Disable a user account."""
|
|
42
|
+
resp = self._t.patch(f"/api/v1/users/{user_id}", json={"is_active": False})
|
|
43
|
+
return resp.json()
|
|
44
|
+
|
|
45
|
+
def enable_user(self, user_id: UUID | str) -> dict[str, Any]:
|
|
46
|
+
"""Re-enable a user account."""
|
|
47
|
+
resp = self._t.patch(f"/api/v1/users/{user_id}", json={"is_active": True})
|
|
48
|
+
return resp.json()
|
|
49
|
+
|
|
50
|
+
# -- API Keys -----------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def list_api_keys(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
status: Optional[str] = None,
|
|
56
|
+
) -> list[APIKey]:
|
|
57
|
+
"""List API keys for the organization."""
|
|
58
|
+
resp = self._t.get("/api/v1/admin/api-keys", params={"status": status})
|
|
59
|
+
data = resp.json()
|
|
60
|
+
items = data if isinstance(data, list) else data.get("items", data.get("keys", []))
|
|
61
|
+
return [APIKey.model_validate(k) for k in items]
|
|
62
|
+
|
|
63
|
+
def create_api_key(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
organization_id: UUID | str,
|
|
67
|
+
name: str,
|
|
68
|
+
description: Optional[str] = None,
|
|
69
|
+
key_type: str = "custom",
|
|
70
|
+
permissions: Optional[list[dict[str, Any]]] = None,
|
|
71
|
+
rate_limit_override: Optional[int] = None,
|
|
72
|
+
ip_whitelist: Optional[list[str]] = None,
|
|
73
|
+
expires_at: Optional[str] = None,
|
|
74
|
+
) -> APIKeyCreated:
|
|
75
|
+
"""Create a new API key. The secret is returned only once."""
|
|
76
|
+
body: dict[str, Any] = {
|
|
77
|
+
"organization_id": str(organization_id),
|
|
78
|
+
"name": name,
|
|
79
|
+
"key_type": key_type,
|
|
80
|
+
}
|
|
81
|
+
if description is not None:
|
|
82
|
+
body["description"] = description
|
|
83
|
+
if permissions is not None:
|
|
84
|
+
body["permissions"] = permissions
|
|
85
|
+
if rate_limit_override is not None:
|
|
86
|
+
body["rate_limit_override"] = rate_limit_override
|
|
87
|
+
if ip_whitelist is not None:
|
|
88
|
+
body["ip_whitelist"] = ip_whitelist
|
|
89
|
+
if expires_at is not None:
|
|
90
|
+
body["expires_at"] = expires_at
|
|
91
|
+
resp = self._t.post("/api/v1/admin/api-keys", json=body)
|
|
92
|
+
return APIKeyCreated.model_validate(resp.json())
|
|
93
|
+
|
|
94
|
+
def revoke_api_key(self, key_id: UUID | str) -> dict[str, Any]:
|
|
95
|
+
"""Revoke an API key immediately."""
|
|
96
|
+
resp = self._t.post(f"/api/v1/admin/api-keys/{key_id}/revoke")
|
|
97
|
+
return resp.json()
|
|
98
|
+
|
|
99
|
+
def get_api_key_usage(self, key_id: UUID | str) -> dict[str, Any]:
|
|
100
|
+
"""Get usage statistics for an API key."""
|
|
101
|
+
resp = self._t.get(f"/api/v1/admin/api-keys/{key_id}/usage")
|
|
102
|
+
return resp.json()
|
|
103
|
+
|
|
104
|
+
# -- Audit Logs ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def get_audit_logs(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
user_id: Optional[str] = None,
|
|
110
|
+
action: Optional[str] = None,
|
|
111
|
+
skip: int = 0,
|
|
112
|
+
limit: int = 50,
|
|
113
|
+
) -> PaginatedResponse:
|
|
114
|
+
"""Query the security audit trail."""
|
|
115
|
+
resp = self._t.get(
|
|
116
|
+
"/api/v1/audit-logs",
|
|
117
|
+
params={"user_id": user_id, "action": action, "skip": skip, "limit": limit},
|
|
118
|
+
)
|
|
119
|
+
return PaginatedResponse.model_validate(resp.json())
|
|
120
|
+
|
|
121
|
+
# -- Devices ------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def list_devices(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
status: Optional[str] = None,
|
|
127
|
+
user_id: Optional[str] = None,
|
|
128
|
+
) -> list[Device]:
|
|
129
|
+
"""List all registered devices (admin view)."""
|
|
130
|
+
resp = self._t.get(
|
|
131
|
+
"/api/v1/admin/devices",
|
|
132
|
+
params={"status": status, "user_id": user_id},
|
|
133
|
+
)
|
|
134
|
+
data = resp.json()
|
|
135
|
+
items = data if isinstance(data, list) else data.get("items", data.get("devices", []))
|
|
136
|
+
return [Device.model_validate(d) for d in items]
|
|
137
|
+
|
|
138
|
+
def create_activation_code(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
target_user_id: Optional[str] = None,
|
|
142
|
+
target_email: Optional[str] = None,
|
|
143
|
+
max_uses: int = 1,
|
|
144
|
+
expiry_hours: int = 24,
|
|
145
|
+
) -> ActivationCode:
|
|
146
|
+
"""Generate a device activation code."""
|
|
147
|
+
body: dict[str, Any] = {"max_uses": max_uses, "expiry_hours": expiry_hours}
|
|
148
|
+
if target_user_id is not None:
|
|
149
|
+
body["target_user_id"] = target_user_id
|
|
150
|
+
if target_email is not None:
|
|
151
|
+
body["target_email"] = target_email
|
|
152
|
+
resp = self._t.post("/api/v1/admin/devices/activation-codes", json=body)
|
|
153
|
+
return ActivationCode.model_validate(resp.json())
|
|
154
|
+
|
|
155
|
+
def suspend_device(self, device_id: UUID | str) -> dict[str, Any]:
|
|
156
|
+
"""Suspend a device."""
|
|
157
|
+
resp = self._t.post(f"/api/v1/admin/devices/{device_id}/suspend")
|
|
158
|
+
return resp.json()
|
|
159
|
+
|
|
160
|
+
def reactivate_device(self, device_id: UUID | str) -> dict[str, Any]:
|
|
161
|
+
"""Reactivate a suspended device."""
|
|
162
|
+
resp = self._t.post(f"/api/v1/admin/devices/{device_id}/reactivate")
|
|
163
|
+
return resp.json()
|
|
164
|
+
|
|
165
|
+
def revoke_device(self, device_id: UUID | str) -> dict[str, Any]:
|
|
166
|
+
"""Force-revoke a device (admin)."""
|
|
167
|
+
resp = self._t.delete(f"/api/v1/admin/devices/{device_id}")
|
|
168
|
+
return resp.json()
|
|
169
|
+
|
|
170
|
+
# -- Org Members --------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def invite_user(
|
|
173
|
+
self,
|
|
174
|
+
org_id: UUID | str,
|
|
175
|
+
*,
|
|
176
|
+
email: str,
|
|
177
|
+
role: str = "member",
|
|
178
|
+
department_id: Optional[UUID | str] = None,
|
|
179
|
+
message: Optional[str] = None,
|
|
180
|
+
) -> OrgInvitation:
|
|
181
|
+
"""Invite a user to the organization."""
|
|
182
|
+
body: dict[str, Any] = {"email": email, "role": role}
|
|
183
|
+
if department_id is not None:
|
|
184
|
+
body["department_id"] = str(department_id)
|
|
185
|
+
if message is not None:
|
|
186
|
+
body["message"] = message
|
|
187
|
+
resp = self._t.post(f"/api/organizations/{org_id}/users", json=body)
|
|
188
|
+
return OrgInvitation.model_validate(resp.json())
|
|
189
|
+
|
|
190
|
+
def list_org_members(
|
|
191
|
+
self,
|
|
192
|
+
org_id: UUID | str,
|
|
193
|
+
*,
|
|
194
|
+
role: Optional[str] = None,
|
|
195
|
+
search: Optional[str] = None,
|
|
196
|
+
skip: int = 0,
|
|
197
|
+
limit: int = 50,
|
|
198
|
+
) -> PaginatedResponse:
|
|
199
|
+
"""List members of an organization."""
|
|
200
|
+
resp = self._t.get(
|
|
201
|
+
f"/api/organizations/{org_id}/users",
|
|
202
|
+
params={"role": role, "search": search, "skip": skip, "limit": limit},
|
|
203
|
+
)
|
|
204
|
+
return PaginatedResponse.model_validate(resp.json())
|
|
205
|
+
|
|
206
|
+
def update_member_role(
|
|
207
|
+
self,
|
|
208
|
+
org_id: UUID | str,
|
|
209
|
+
user_id: int | str,
|
|
210
|
+
*,
|
|
211
|
+
role: Optional[str] = None,
|
|
212
|
+
department_id: Optional[UUID | str] = None,
|
|
213
|
+
is_active: Optional[bool] = None,
|
|
214
|
+
) -> OrgMember:
|
|
215
|
+
"""Update an organization member's role or status."""
|
|
216
|
+
body: dict[str, Any] = {}
|
|
217
|
+
if role is not None:
|
|
218
|
+
body["role"] = role
|
|
219
|
+
if department_id is not None:
|
|
220
|
+
body["department_id"] = str(department_id)
|
|
221
|
+
if is_active is not None:
|
|
222
|
+
body["is_active"] = is_active
|
|
223
|
+
resp = self._t.patch(f"/api/organizations/{org_id}/users/{user_id}", json=body)
|
|
224
|
+
return OrgMember.model_validate(resp.json())
|
|
225
|
+
|
|
226
|
+
def remove_member(self, org_id: UUID | str, user_id: int | str) -> None:
|
|
227
|
+
"""Remove a member from the organization."""
|
|
228
|
+
self._t.delete(f"/api/organizations/{org_id}/users/{user_id}")
|
|
229
|
+
|
|
230
|
+
def accept_invitation(self, invitation_id: int | str, token: str) -> dict[str, Any]:
|
|
231
|
+
"""Accept an organization invitation."""
|
|
232
|
+
resp = self._t.post(
|
|
233
|
+
f"/api/users/invitations/{invitation_id}/accept",
|
|
234
|
+
json={"token": token},
|
|
235
|
+
)
|
|
236
|
+
return resp.json()
|
|
237
|
+
|
|
238
|
+
def decline_invitation(
|
|
239
|
+
self,
|
|
240
|
+
invitation_id: int | str,
|
|
241
|
+
token: str,
|
|
242
|
+
*,
|
|
243
|
+
reason: Optional[str] = None,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Decline an organization invitation."""
|
|
246
|
+
body: dict[str, Any] = {"token": token}
|
|
247
|
+
if reason is not None:
|
|
248
|
+
body["reason"] = reason
|
|
249
|
+
resp = self._t.post(f"/api/users/invitations/{invitation_id}/decline", json=body)
|
|
250
|
+
return resp.json()
|
documentors/client.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Top-level DocumentorsClient — single entry point for the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._transport import Transport
|
|
8
|
+
from .admin import Admin
|
|
9
|
+
from .compliance import Compliance
|
|
10
|
+
from .documents import Documents
|
|
11
|
+
from .models import TokenResponse
|
|
12
|
+
from .security import Security
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DocumentorsClient:
|
|
16
|
+
"""Synchronous client for the DocuMentors platform API.
|
|
17
|
+
|
|
18
|
+
Authenticate with either an API key or JWT credentials::
|
|
19
|
+
|
|
20
|
+
# API key auth (recommended for integrations)
|
|
21
|
+
client = DocumentorsClient(
|
|
22
|
+
base_url="https://sandbox.docu-mentors.com",
|
|
23
|
+
api_key="dk_sandbox_abc123...",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# JWT auth (interactive / admin scripts)
|
|
27
|
+
client = DocumentorsClient(
|
|
28
|
+
base_url="https://sandbox.docu-mentors.com",
|
|
29
|
+
)
|
|
30
|
+
client.login(email="admin@example.com", password="secret")
|
|
31
|
+
|
|
32
|
+
Resource namespaces:
|
|
33
|
+
|
|
34
|
+
- ``client.documents`` — upload, list, download, delete, versions
|
|
35
|
+
- ``client.security`` — PII detection, phishing analysis, redaction
|
|
36
|
+
- ``client.compliance`` — legal holds, eDiscovery, records management
|
|
37
|
+
- ``client.admin`` — users, API keys, audit logs, devices
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
base_url: str,
|
|
43
|
+
*,
|
|
44
|
+
api_key: Optional[str] = None,
|
|
45
|
+
jwt_token: Optional[str] = None,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
backoff_factor: float = 0.5,
|
|
49
|
+
) -> None:
|
|
50
|
+
headers: dict[str, str] = {
|
|
51
|
+
"Accept": "application/json",
|
|
52
|
+
"User-Agent": "documentors-python/0.1.0",
|
|
53
|
+
}
|
|
54
|
+
if api_key is not None:
|
|
55
|
+
headers["X-API-Key"] = api_key
|
|
56
|
+
elif jwt_token is not None:
|
|
57
|
+
headers["Authorization"] = f"Bearer {jwt_token}"
|
|
58
|
+
|
|
59
|
+
self._transport = Transport(
|
|
60
|
+
base_url=base_url,
|
|
61
|
+
headers=headers,
|
|
62
|
+
timeout=timeout,
|
|
63
|
+
max_retries=max_retries,
|
|
64
|
+
backoff_factor=backoff_factor,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.documents = Documents(self._transport)
|
|
68
|
+
self.security = Security(self._transport)
|
|
69
|
+
self.compliance = Compliance(self._transport)
|
|
70
|
+
self.admin = Admin(self._transport)
|
|
71
|
+
|
|
72
|
+
def login(self, *, email: str, password: str) -> TokenResponse:
|
|
73
|
+
"""Authenticate with email/password and store the JWT for subsequent requests.
|
|
74
|
+
|
|
75
|
+
Returns the token response including user details.
|
|
76
|
+
"""
|
|
77
|
+
resp = self._transport.post(
|
|
78
|
+
"/api/v1/auth/login",
|
|
79
|
+
json={"email": email, "password": password},
|
|
80
|
+
)
|
|
81
|
+
token = TokenResponse.model_validate(resp.json())
|
|
82
|
+
self._transport._headers["Authorization"] = f"Bearer {token.access_token}"
|
|
83
|
+
return token
|
|
84
|
+
|
|
85
|
+
def health(self) -> dict:
|
|
86
|
+
"""Check platform health."""
|
|
87
|
+
resp = self._transport.get("/health")
|
|
88
|
+
return resp.json()
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
"""Close the underlying HTTP connection pool."""
|
|
92
|
+
self._transport.close()
|
|
93
|
+
|
|
94
|
+
def __enter__(self) -> DocumentorsClient:
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, *args: object) -> None:
|
|
98
|
+
self.close()
|