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.
@@ -0,0 +1,137 @@
1
+ """Compliance operations — legal holds, eDiscovery, records management."""
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 LegalHold, RetentionPolicy
10
+
11
+
12
+ class Compliance:
13
+ """``client.compliance.*`` — governance and compliance workflows."""
14
+
15
+ def __init__(self, transport: Transport) -> None:
16
+ self._t = transport
17
+
18
+ # -- Legal Holds --------------------------------------------------------
19
+
20
+ def create_legal_hold(
21
+ self,
22
+ *,
23
+ name: str,
24
+ description: Optional[str] = None,
25
+ custodian_ids: Optional[list[str]] = None,
26
+ ) -> LegalHold:
27
+ """Create a new legal hold."""
28
+ body: dict[str, Any] = {"name": name}
29
+ if description is not None:
30
+ body["description"] = description
31
+ if custodian_ids is not None:
32
+ body["custodian_ids"] = custodian_ids
33
+ resp = self._t.post("/api/v1/governance/holds", json=body)
34
+ return LegalHold.model_validate(resp.json())
35
+
36
+ def get_legal_hold(self, hold_id: UUID | str) -> LegalHold:
37
+ """Get a legal hold by ID."""
38
+ resp = self._t.get(f"/api/v1/governance/holds/{hold_id}")
39
+ return LegalHold.model_validate(resp.json())
40
+
41
+ def list_legal_holds(self, *, status: Optional[str] = None) -> list[LegalHold]:
42
+ """List all legal holds."""
43
+ resp = self._t.get("/api/v1/governance/holds", params={"status": status})
44
+ data = resp.json()
45
+ items = data if isinstance(data, list) else data.get("items", data.get("holds", []))
46
+ return [LegalHold.model_validate(h) for h in items]
47
+
48
+ def release_legal_hold(self, hold_id: UUID | str) -> LegalHold:
49
+ """Release (close) a legal hold."""
50
+ resp = self._t.post(f"/api/v1/governance/holds/{hold_id}/release")
51
+ return LegalHold.model_validate(resp.json())
52
+
53
+ def add_custodian(self, hold_id: UUID | str, *, user_id: str) -> dict[str, Any]:
54
+ """Add a custodian to a legal hold."""
55
+ resp = self._t.post(
56
+ f"/api/v1/governance/holds/{hold_id}/custodians",
57
+ json={"user_id": user_id},
58
+ )
59
+ return resp.json()
60
+
61
+ def place_document(self, hold_id: UUID | str, *, document_id: str) -> dict[str, Any]:
62
+ """Place a document under legal hold (prevents deletion)."""
63
+ resp = self._t.post(
64
+ f"/api/v1/governance/holds/{hold_id}/documents",
65
+ json={"document_id": document_id},
66
+ )
67
+ return resp.json()
68
+
69
+ def get_custody_chain(self, hold_id: UUID | str) -> list[dict[str, Any]]:
70
+ """Get HMAC-signed chain of custody logs."""
71
+ resp = self._t.get(f"/api/v1/governance/holds/{hold_id}/custody-chain")
72
+ data = resp.json()
73
+ return data if isinstance(data, list) else data.get("entries", [])
74
+
75
+ # -- eDiscovery ---------------------------------------------------------
76
+
77
+ def search(
78
+ self,
79
+ *,
80
+ query: str,
81
+ date_from: Optional[str] = None,
82
+ date_to: Optional[str] = None,
83
+ custodians: Optional[list[str]] = None,
84
+ limit: int = 50,
85
+ ) -> dict[str, Any]:
86
+ """Run an eDiscovery search (boolean / proximity)."""
87
+ body: dict[str, Any] = {"query": query, "limit": limit}
88
+ if date_from is not None:
89
+ body["date_from"] = date_from
90
+ if date_to is not None:
91
+ body["date_to"] = date_to
92
+ if custodians is not None:
93
+ body["custodians"] = custodians
94
+ resp = self._t.post("/api/v1/ediscovery/search", json=body)
95
+ return resp.json()
96
+
97
+ def create_review_set(self, *, name: str, search_id: Optional[str] = None) -> dict[str, Any]:
98
+ """Create a review set for tagging and production."""
99
+ body: dict[str, Any] = {"name": name}
100
+ if search_id is not None:
101
+ body["search_id"] = search_id
102
+ resp = self._t.post("/api/v1/ediscovery/review-sets", json=body)
103
+ return resp.json()
104
+
105
+ # -- Records Management -------------------------------------------------
106
+
107
+ def list_retention_policies(self) -> list[RetentionPolicy]:
108
+ """List all retention policies."""
109
+ resp = self._t.get("/api/v1/governance/retention/policies")
110
+ data = resp.json()
111
+ items = data if isinstance(data, list) else data.get("items", data.get("policies", []))
112
+ return [RetentionPolicy.model_validate(p) for p in items]
113
+
114
+ def create_retention_policy(
115
+ self,
116
+ *,
117
+ name: str,
118
+ retention_days: int,
119
+ template: Optional[str] = None,
120
+ ) -> RetentionPolicy:
121
+ """Create a retention policy.
122
+
123
+ ``template`` can be ``HIPAA_6YR``, ``SOX_7YR``, ``GDPR_3YR``, ``IRS_7YR``.
124
+ """
125
+ body: dict[str, Any] = {"name": name, "retention_days": retention_days}
126
+ if template is not None:
127
+ body["template"] = template
128
+ resp = self._t.post("/api/v1/governance/retention/policies", json=body)
129
+ return RetentionPolicy.model_validate(resp.json())
130
+
131
+ def declare_record(self, document_id: UUID | str, *, policy_id: str) -> dict[str, Any]:
132
+ """Declare a document as a record under a retention policy."""
133
+ resp = self._t.post(
134
+ f"/api/v1/records/declare",
135
+ json={"policy_id": policy_id},
136
+ )
137
+ return resp.json()
@@ -0,0 +1,118 @@
1
+ """Document operations — upload, list, get, delete, versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, BinaryIO, Optional
7
+ from uuid import UUID
8
+
9
+ from ._transport import Transport
10
+ from .models import Document, DocumentVersion, PaginatedResponse
11
+
12
+
13
+ class Documents:
14
+ """``client.documents.*`` — CRUD and version management."""
15
+
16
+ _PREFIX = "/api/v1/documents"
17
+
18
+ def __init__(self, transport: Transport) -> None:
19
+ self._t = transport
20
+
21
+ def list(
22
+ self,
23
+ *,
24
+ status: Optional[str] = None,
25
+ search: Optional[str] = None,
26
+ skip: int = 0,
27
+ limit: int = 50,
28
+ ) -> PaginatedResponse:
29
+ """List documents with optional filters."""
30
+ resp = self._t.get(
31
+ self._PREFIX,
32
+ params={"status": status, "search": search, "skip": skip, "limit": limit},
33
+ )
34
+ return PaginatedResponse.model_validate(resp.json())
35
+
36
+ def get(self, document_id: UUID | str) -> Document:
37
+ """Retrieve a single document by ID."""
38
+ resp = self._t.get(f"{self._PREFIX}/{document_id}")
39
+ return Document.model_validate(resp.json())
40
+
41
+ def upload(
42
+ self,
43
+ file: str | Path | BinaryIO,
44
+ *,
45
+ title: Optional[str] = None,
46
+ classification: Optional[str] = None,
47
+ project_id: Optional[str] = None,
48
+ metadata: Optional[dict[str, Any]] = None,
49
+ ) -> Document:
50
+ """Upload a new document.
51
+
52
+ ``file`` may be a file path, or an open binary file object.
53
+ """
54
+ if isinstance(file, (str, Path)):
55
+ path = Path(file)
56
+ with open(path, "rb") as fh:
57
+ return self._do_upload(
58
+ fh,
59
+ filename=path.name,
60
+ title=title,
61
+ classification=classification,
62
+ project_id=project_id,
63
+ metadata=metadata,
64
+ )
65
+ return self._do_upload(
66
+ file,
67
+ filename=getattr(file, "name", "upload"),
68
+ title=title,
69
+ classification=classification,
70
+ project_id=project_id,
71
+ metadata=metadata,
72
+ )
73
+
74
+ def delete(self, document_id: UUID | str) -> None:
75
+ """Delete a document by ID."""
76
+ self._t.delete(f"{self._PREFIX}/{document_id}")
77
+
78
+ def versions(self, document_id: UUID | str) -> list[DocumentVersion]:
79
+ """List version history for a document."""
80
+ resp = self._t.get(f"{self._PREFIX}/{document_id}/versions")
81
+ data = resp.json()
82
+ items = data if isinstance(data, list) else data.get("items", data.get("versions", []))
83
+ return [DocumentVersion.model_validate(v) for v in items]
84
+
85
+ def download(self, document_id: UUID | str) -> bytes:
86
+ """Download the raw content of a document."""
87
+ resp = self._t.get(f"{self._PREFIX}/{document_id}/download")
88
+ return resp.content
89
+
90
+ # -- internal -----------------------------------------------------------
91
+
92
+ def _do_upload(
93
+ self,
94
+ fh: BinaryIO,
95
+ *,
96
+ filename: str,
97
+ title: Optional[str],
98
+ classification: Optional[str],
99
+ project_id: Optional[str],
100
+ metadata: Optional[dict[str, Any]],
101
+ ) -> Document:
102
+ form: dict[str, Any] = {}
103
+ if title is not None:
104
+ form["title"] = title
105
+ if classification is not None:
106
+ form["classification"] = classification
107
+ if project_id is not None:
108
+ form["project_id"] = project_id
109
+ if metadata is not None:
110
+ import json
111
+ form["metadata"] = json.dumps(metadata)
112
+
113
+ resp = self._t.post(
114
+ f"{self._PREFIX}/upload",
115
+ files={"file": (filename, fh)},
116
+ data=form,
117
+ )
118
+ return Document.model_validate(resp.json())
@@ -0,0 +1,55 @@
1
+ """Exceptions raised by the DocumentorsClient SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class DocumentorsError(Exception):
9
+ """Base exception for all SDK errors."""
10
+
11
+ def __init__(self, message: str, *, details: Optional[dict[str, Any]] = None) -> None:
12
+ super().__init__(message)
13
+ self.details = details or {}
14
+
15
+
16
+ class AuthenticationError(DocumentorsError):
17
+ """Raised when authentication fails (401)."""
18
+
19
+
20
+ class PermissionDeniedError(DocumentorsError):
21
+ """Raised when the caller lacks required scope or role (403)."""
22
+
23
+
24
+ class NotFoundError(DocumentorsError):
25
+ """Raised when the requested resource does not exist (404)."""
26
+
27
+
28
+ class ValidationError(DocumentorsError):
29
+ """Raised when request validation fails (422)."""
30
+
31
+
32
+ class RateLimitError(DocumentorsError):
33
+ """Raised when rate limit is exceeded (429).
34
+
35
+ Attributes:
36
+ retry_after: Seconds until the caller may retry.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ message: str = "Rate limit exceeded",
42
+ *,
43
+ retry_after: Optional[int] = None,
44
+ details: Optional[dict[str, Any]] = None,
45
+ ) -> None:
46
+ super().__init__(message, details=details)
47
+ self.retry_after = retry_after
48
+
49
+
50
+ class ServerError(DocumentorsError):
51
+ """Raised on 5xx responses from the platform."""
52
+
53
+
54
+ class ConflictError(DocumentorsError):
55
+ """Raised when the operation conflicts with current state (409)."""
documentors/models.py ADDED
@@ -0,0 +1,224 @@
1
+ """Pydantic response models for the DocuMentors API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any, Optional
7
+ from uuid import UUID
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Pagination envelope
14
+ # ---------------------------------------------------------------------------
15
+
16
+ class PaginatedResponse(BaseModel):
17
+ """Wrapper returned by paginated list endpoints."""
18
+
19
+ items: list[dict[str, Any]] = Field(default_factory=list)
20
+ total: int = 0
21
+ skip: int = 0
22
+ limit: int = 50
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Auth
27
+ # ---------------------------------------------------------------------------
28
+
29
+ class TokenUser(BaseModel):
30
+ email: str
31
+ full_name: Optional[str] = None
32
+ is_superuser: bool = False
33
+
34
+
35
+ class TokenResponse(BaseModel):
36
+ access_token: str
37
+ token_type: str = "bearer"
38
+ expires_in: int = 900
39
+ user: Optional[TokenUser] = None
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Documents
44
+ # ---------------------------------------------------------------------------
45
+
46
+ class Document(BaseModel):
47
+ id: UUID
48
+ title: Optional[str] = None
49
+ filename: Optional[str] = None
50
+ status: Optional[str] = None
51
+ classification: Optional[str] = None
52
+ organization_id: Optional[UUID] = None
53
+ created_at: Optional[datetime] = None
54
+ updated_at: Optional[datetime] = None
55
+ file_size: Optional[int] = None
56
+ content_type: Optional[str] = None
57
+ version: Optional[int] = None
58
+
59
+
60
+ class DocumentVersion(BaseModel):
61
+ id: UUID
62
+ document_id: UUID
63
+ version_number: int
64
+ created_at: Optional[datetime] = None
65
+ created_by: Optional[str] = None
66
+ change_summary: Optional[str] = None
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # PII / Security
71
+ # ---------------------------------------------------------------------------
72
+
73
+ class PIIFinding(BaseModel):
74
+ type: str
75
+ confidence: float
76
+ start_offset: Optional[int] = None
77
+ end_offset: Optional[int] = None
78
+ masked_value: Optional[str] = None
79
+ risk_level: Optional[str] = None
80
+
81
+
82
+ class ScanResult(BaseModel):
83
+ scan_id: UUID
84
+ document_id: UUID
85
+ status: str = "completed"
86
+ findings: list[PIIFinding] = Field(default_factory=list)
87
+ entity_count: Optional[int] = None
88
+ scanned_at: Optional[datetime] = None
89
+
90
+
91
+ class ScanHistoryEntry(BaseModel):
92
+ scan_id: UUID
93
+ document_id: UUID
94
+ status: str
95
+ entity_count: Optional[int] = None
96
+ scanned_at: Optional[datetime] = None
97
+
98
+
99
+ class PhishingVerdict(BaseModel):
100
+ is_phishing: bool
101
+ confidence: float
102
+ verdict: str = "legitimate"
103
+ signals: Optional[dict[str, float]] = None
104
+ threat_indicators: list[str] = Field(default_factory=list)
105
+
106
+
107
+ class URLThreat(BaseModel):
108
+ url: str
109
+ is_threat: bool
110
+ confidence: float = 0.0
111
+ threat_type: Optional[str] = None
112
+ source: Optional[str] = None
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Governance / Compliance
117
+ # ---------------------------------------------------------------------------
118
+
119
+ class LegalHold(BaseModel):
120
+ id: UUID
121
+ name: str
122
+ status: Optional[str] = None
123
+ description: Optional[str] = None
124
+ created_at: Optional[datetime] = None
125
+ created_by: Optional[str] = None
126
+ released_at: Optional[datetime] = None
127
+
128
+
129
+ class RetentionPolicy(BaseModel):
130
+ id: UUID
131
+ name: str
132
+ retention_days: int
133
+ template: Optional[str] = None
134
+ status: Optional[str] = None
135
+ created_at: Optional[datetime] = None
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Admin / Users
140
+ # ---------------------------------------------------------------------------
141
+
142
+ class User(BaseModel):
143
+ id: UUID
144
+ email: str
145
+ full_name: Optional[str] = None
146
+ is_active: bool = True
147
+ is_superuser: bool = False
148
+ organization_id: Optional[UUID] = None
149
+ org_role: Optional[str] = None
150
+ created_at: Optional[datetime] = None
151
+
152
+
153
+ class APIKey(BaseModel):
154
+ id: UUID
155
+ api_key_prefix: str
156
+ name: str
157
+ is_active: bool = True
158
+ created_at: Optional[datetime] = None
159
+ last_used_at: Optional[datetime] = None
160
+ expires_at: Optional[datetime] = None
161
+ rate_limit_override: Optional[int] = None
162
+
163
+
164
+ class APIKeyCreated(APIKey):
165
+ """Returned only on key creation — contains the one-time secret."""
166
+
167
+ api_key_secret: str
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Devices
172
+ # ---------------------------------------------------------------------------
173
+
174
+ class Device(BaseModel):
175
+ id: UUID
176
+ device_name: str
177
+ status: Optional[str] = None
178
+ activated_at: Optional[datetime] = None
179
+ expires_at: Optional[datetime] = None
180
+ last_seen_at: Optional[datetime] = None
181
+
182
+
183
+ class ActivationCode(BaseModel):
184
+ id: UUID
185
+ code: Optional[str] = None
186
+ max_uses: int = 1
187
+ uses: int = 0
188
+ expires_at: Optional[datetime] = None
189
+ created_at: Optional[datetime] = None
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Org Members
194
+ # ---------------------------------------------------------------------------
195
+
196
+ class OrgMember(BaseModel):
197
+ id: int
198
+ organization_id: UUID
199
+ user_id: int
200
+ role: str
201
+ department_id: Optional[UUID] = None
202
+ is_active: bool = True
203
+ joined_at: Optional[datetime] = None
204
+ last_active_at: Optional[datetime] = None
205
+ user_email: str = ""
206
+ user_username: str = ""
207
+ user_full_name: Optional[str] = None
208
+ department_name: Optional[str] = None
209
+ documents_count: int = 0
210
+ projects_count: int = 0
211
+
212
+
213
+ class OrgInvitation(BaseModel):
214
+ id: int
215
+ organization_id: UUID
216
+ organization_name: str = ""
217
+ email: str
218
+ role: str = "member"
219
+ department_id: Optional[UUID] = None
220
+ invited_by_id: int
221
+ invited_by_name: str = ""
222
+ status: str # pending, accepted, declined, expired
223
+ invited_at: Optional[datetime] = None
224
+ expires_at: Optional[datetime] = None
@@ -0,0 +1,132 @@
1
+ """Security operations — PII detection, phishing analysis, redaction."""
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 PhishingVerdict, PIIFinding, ScanHistoryEntry, ScanResult, URLThreat
10
+
11
+
12
+ class Security:
13
+ """``client.security.*`` — PII scanning, phishing detection, redaction."""
14
+
15
+ def __init__(self, transport: Transport) -> None:
16
+ self._t = transport
17
+
18
+ # -- PII scanning -------------------------------------------------------
19
+
20
+ def detect_pii(
21
+ self,
22
+ document_id: UUID | str,
23
+ *,
24
+ min_confidence: float = 0.70,
25
+ ) -> ScanResult:
26
+ """Trigger a PII scan on a document. Returns findings."""
27
+ resp = self._t.post(
28
+ f"/api/v1/documents/{document_id}/scan",
29
+ json={"min_confidence": min_confidence},
30
+ )
31
+ return ScanResult.model_validate(resp.json())
32
+
33
+ def get_scan_results(
34
+ self,
35
+ document_id: UUID | str,
36
+ *,
37
+ scan_id: Optional[UUID | str] = None,
38
+ ) -> ScanResult:
39
+ """Retrieve results for a specific (or latest) scan."""
40
+ params: dict[str, Any] = {}
41
+ if scan_id is not None:
42
+ params["scan_id"] = str(scan_id)
43
+ resp = self._t.get(f"/api/v1/documents/{document_id}/scan-results", params=params)
44
+ return ScanResult.model_validate(resp.json())
45
+
46
+ def scan_history(self, document_id: UUID | str) -> list[ScanHistoryEntry]:
47
+ """List past scans for a document."""
48
+ resp = self._t.get(f"/api/v1/documents/{document_id}/scan-history")
49
+ data = resp.json()
50
+ items = data if isinstance(data, list) else data.get("items", [])
51
+ return [ScanHistoryEntry.model_validate(e) for e in items]
52
+
53
+ def redact_pii(
54
+ self,
55
+ document_id: UUID | str,
56
+ *,
57
+ strategy: str = "MASK",
58
+ entity_types: Optional[list[str]] = None,
59
+ ) -> dict[str, Any]:
60
+ """Apply redaction to detected PII entities.
61
+
62
+ ``strategy`` can be ``MASK``, ``REPLACE``, or ``REMOVE``.
63
+ """
64
+ body: dict[str, Any] = {"strategy": strategy}
65
+ if entity_types is not None:
66
+ body["entity_types"] = entity_types
67
+ resp = self._t.post(f"/api/v1/documents/{document_id}/redact", json=body)
68
+ return resp.json()
69
+
70
+ # -- Phishing detection -------------------------------------------------
71
+
72
+ def analyze_email(
73
+ self,
74
+ *,
75
+ email_content: str,
76
+ sender: str,
77
+ recipient: str,
78
+ subject: str,
79
+ headers: Optional[dict[str, str]] = None,
80
+ ) -> PhishingVerdict:
81
+ """Analyze a single email for phishing indicators."""
82
+ body: dict[str, Any] = {
83
+ "email_content": email_content,
84
+ "sender": sender,
85
+ "recipient": recipient,
86
+ "subject": subject,
87
+ }
88
+ if headers is not None:
89
+ body["headers"] = headers
90
+ resp = self._t.post("/api/v1/phishing/analyze/email", json=body)
91
+ return PhishingVerdict.model_validate(resp.json())
92
+
93
+ def analyze_emails(
94
+ self,
95
+ emails: list[dict[str, Any]],
96
+ ) -> list[PhishingVerdict]:
97
+ """Batch-analyze up to 50 emails."""
98
+ resp = self._t.post("/api/v1/phishing/analyze/email/batch", json={"emails": emails})
99
+ data = resp.json()
100
+ items = data if isinstance(data, list) else data.get("results", [])
101
+ return [PhishingVerdict.model_validate(v) for v in items]
102
+
103
+ def analyze_url(self, url: str) -> URLThreat:
104
+ """Check a single URL against threat intelligence."""
105
+ resp = self._t.post("/api/v1/phishing/analyze/url", json={"url": url})
106
+ return URLThreat.model_validate(resp.json())
107
+
108
+ def analyze_urls(self, urls: list[str]) -> list[URLThreat]:
109
+ """Batch-check up to 200 URLs."""
110
+ resp = self._t.post("/api/v1/phishing/analyze/urls", json={"urls": urls})
111
+ data = resp.json()
112
+ items = data if isinstance(data, list) else data.get("results", [])
113
+ return [URLThreat.model_validate(t) for t in items]
114
+
115
+ def check_domain(self, domain: str) -> dict[str, Any]:
116
+ """Get domain reputation information."""
117
+ resp = self._t.get("/api/v1/phishing/analyze/domain", params={"domain": domain})
118
+ return resp.json()
119
+
120
+ def submit_feedback(
121
+ self,
122
+ *,
123
+ verdict_id: str,
124
+ is_correct: bool,
125
+ notes: Optional[str] = None,
126
+ ) -> dict[str, Any]:
127
+ """Submit false-positive / false-negative feedback."""
128
+ body: dict[str, Any] = {"verdict_id": verdict_id, "is_correct": is_correct}
129
+ if notes is not None:
130
+ body["notes"] = notes
131
+ resp = self._t.post("/api/v1/phishing/feedback", json=body)
132
+ return resp.json()