vaultkit 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.
vaultkit/core/http.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional, Callable
5
+ import logging
6
+
7
+ import httpx
8
+
9
+ from vaultkit.errors.base import ErrorContext
10
+ from vaultkit.errors.exceptions import map_http_error, TransportError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class HttpConfig:
15
+ timeout_s: float = 15.0
16
+ user_agent: str = "vaultkit-python/1.0"
17
+
18
+
19
+ class HttpClient:
20
+ def __init__(
21
+ self,
22
+ *,
23
+ base_url: str,
24
+ token: str,
25
+ config: Optional[HttpConfig] = None,
26
+ retry_fn: Optional[Callable[[Callable[[], httpx.Response]], httpx.Response]] = None,
27
+ logger: Optional[logging.Logger] = None,
28
+ ):
29
+ self.base_url = base_url.rstrip("/")
30
+ self.token = token
31
+ self.config = config or HttpConfig()
32
+ self._client = httpx.Client(timeout=self.config.timeout_s)
33
+ self._retry_fn = retry_fn # optional retry wrapper
34
+ self._logger = logger
35
+
36
+ self._default_headers = {
37
+ "Authorization": f"Bearer {self.token}",
38
+ "Content-Type": "application/json",
39
+ "Accept": "application/json",
40
+ "User-Agent": self.config.user_agent,
41
+ }
42
+
43
+ def close(self) -> None:
44
+ self._client.close()
45
+
46
+ # public API
47
+
48
+ def get(
49
+ self,
50
+ path: str,
51
+ *,
52
+ params: Optional[Dict[str, Any]] = None,
53
+ ) -> Dict[str, Any]:
54
+ return self._request("GET", path, params=params)
55
+
56
+ def post(
57
+ self,
58
+ path: str,
59
+ *,
60
+ json_body: Dict[str, Any],
61
+ ) -> Dict[str, Any]:
62
+ return self._request("POST", path, json=json_body)
63
+
64
+ # internal
65
+
66
+ def _request(
67
+ self,
68
+ method: str,
69
+ path: str,
70
+ *,
71
+ params: Optional[Dict[str, Any]] = None,
72
+ json: Optional[Dict[str, Any]] = None,
73
+ ) -> Dict[str, Any]:
74
+ url = f"{self.base_url}/{path.lstrip('/')}"
75
+
76
+ if self._logger:
77
+ self._logger.debug(
78
+ "[VaultKit] HTTP request",
79
+ extra={
80
+ "method": method,
81
+ "path": path,
82
+ },
83
+ )
84
+
85
+ def send() -> httpx.Response:
86
+ return self._client.request(
87
+ method,
88
+ url,
89
+ headers=self._default_headers,
90
+ params=params,
91
+ json=json,
92
+ )
93
+
94
+ try:
95
+ res = self._retry_fn(send) if self._retry_fn else send()
96
+ return self._handle(res, method=method, path=path)
97
+ except httpx.RequestError as e:
98
+ if self._logger:
99
+ self._logger.error(
100
+ "[VaultKit] Network error",
101
+ extra={
102
+ "method": method,
103
+ "path": path,
104
+ "error": str(e),
105
+ },
106
+ )
107
+
108
+ raise TransportError(
109
+ f"Network error: {e}",
110
+ context=ErrorContext(raw=str(e)),
111
+ ) from e
112
+
113
+ def _handle(self, res: httpx.Response, *, method: str, path: str) -> Dict[str, Any]:
114
+ correlation_id = (
115
+ res.headers.get("x-correlation-id")
116
+ or res.headers.get("correlation_id")
117
+ )
118
+
119
+ if self._logger:
120
+ self._logger.debug(
121
+ "[VaultKit] HTTP response",
122
+ extra={
123
+ "method": method,
124
+ "path": path,
125
+ "status_code": res.status_code,
126
+ "correlation_id": correlation_id,
127
+ },
128
+ )
129
+
130
+ # success
131
+ if 200 <= res.status_code < 300:
132
+ if not res.content:
133
+ return {}
134
+
135
+ try:
136
+ data = res.json()
137
+ except Exception as e:
138
+ if self._logger:
139
+ self._logger.error(
140
+ "[VaultKit] Invalid JSON response",
141
+ extra={
142
+ "status_code": res.status_code,
143
+ "correlation_id": correlation_id,
144
+ },
145
+ )
146
+
147
+ raise TransportError(
148
+ "Invalid JSON response from VaultKit",
149
+ context=ErrorContext(
150
+ status_code=res.status_code,
151
+ correlation_id=correlation_id,
152
+ raw=res.text,
153
+ ),
154
+ ) from e
155
+
156
+ return data if isinstance(data, dict) else {"data": data}
157
+
158
+ # error
159
+ raw: Any = None
160
+ msg = f"VaultKit API error ({res.status_code})"
161
+
162
+ try:
163
+ raw = res.json()
164
+ if isinstance(raw, dict):
165
+ msg = (
166
+ raw.get("error")
167
+ or raw.get("message")
168
+ or raw.get("detail")
169
+ or msg
170
+ )
171
+ except Exception:
172
+ raw = res.text
173
+
174
+ if self._logger:
175
+ self._logger.error(
176
+ "[VaultKit] API error",
177
+ extra={
178
+ "status_code": res.status_code,
179
+ "correlation_id": correlation_id,
180
+ "message": msg,
181
+ },
182
+ )
183
+
184
+ ctx = ErrorContext(
185
+ status_code=res.status_code,
186
+ correlation_id=correlation_id,
187
+ raw=raw,
188
+ )
189
+
190
+ raise map_http_error(res.status_code, msg, context=ctx)
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import random
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Optional
8
+
9
+ from vaultkit.errors.exceptions import QueuedError, PollTimeoutError, NotFoundError
10
+ from vaultkit.models.query_result import QueryResult
11
+
12
+ _TERMINAL_STATUSES = {"granted", "denied", "pending_approval"}
13
+ _APPROVAL_STATUSES = {"queued", "pending_approval"}
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PollConfig:
18
+ interval_s: float = 2.0
19
+ timeout_s: float = 60.0
20
+
21
+ # Approval-specific backoff (long waits expected)
22
+ approval_backoff_factor: float = 1.5
23
+ approval_max_interval_s: float = 15.0
24
+
25
+ # Jitter percentage (±10% by default)
26
+ jitter_ratio: float = 0.1
27
+
28
+
29
+ def poll_until_done(
30
+ *,
31
+ initial: QueryResult,
32
+ poll_fn: Callable[[str], QueryResult],
33
+ config: PollConfig,
34
+ logger: Optional[logging.Logger] = None,
35
+ ) -> QueryResult:
36
+ # If already in a terminal state, return immediately
37
+ if initial.status in _TERMINAL_STATUSES:
38
+ if logger:
39
+ logger.debug(
40
+ "[VaultKit] Poll skipped (already terminal)",
41
+ extra={"status": initial.status, "request_id": initial.request_id},
42
+ )
43
+ return initial
44
+
45
+ if not initial.request_id:
46
+ raise QueuedError(
47
+ "Request is queued but no request_id was provided; cannot poll safely."
48
+ )
49
+
50
+ request_id = initial.request_id
51
+ deadline = time.time() + config.timeout_s
52
+
53
+ current = initial
54
+ interval = config.interval_s
55
+ attempt = 0
56
+
57
+ while time.time() < deadline:
58
+ attempt += 1
59
+
60
+ # Apply jitter to avoid thundering herd issues when many requests are queued
61
+ jitter = random.uniform(
62
+ -interval * config.jitter_ratio,
63
+ interval * config.jitter_ratio,
64
+ )
65
+ sleep_time = max(0.0, interval + jitter)
66
+
67
+ # Ensure we don't sleep past the deadline
68
+ remaining = deadline - time.time()
69
+ if remaining <= 0:
70
+ break
71
+
72
+ if logger:
73
+ logger.debug(
74
+ "[VaultKit] Polling request",
75
+ extra={
76
+ "request_id": request_id,
77
+ "attempt": attempt,
78
+ "interval": round(interval, 2),
79
+ },
80
+ )
81
+
82
+ time.sleep(min(sleep_time, remaining))
83
+
84
+ try:
85
+ current = poll_fn(request_id)
86
+ except NotFoundError:
87
+ # Request disappeared (expired / deleted / invalid)
88
+ if logger:
89
+ logger.error(
90
+ "[VaultKit] Request not found during polling",
91
+ extra={"request_id": request_id},
92
+ )
93
+ raise PollTimeoutError(
94
+ "Request not found while polling",
95
+ request_id=request_id,
96
+ )
97
+
98
+ if logger:
99
+ logger.debug(
100
+ "[VaultKit] Poll result",
101
+ extra={
102
+ "request_id": request_id,
103
+ "status": current.status,
104
+ },
105
+ )
106
+
107
+ # If we've reached a terminal state, return result
108
+ if current.status in _TERMINAL_STATUSES:
109
+ if logger:
110
+ logger.info(
111
+ "[VaultKit] Polling complete",
112
+ extra={
113
+ "request_id": request_id,
114
+ "status": current.status,
115
+ },
116
+ )
117
+ return current
118
+
119
+ # Adjust interval based on status
120
+ if current.status in _APPROVAL_STATUSES:
121
+ # Slow down polling for approvals
122
+ interval = min(
123
+ interval * config.approval_backoff_factor,
124
+ config.approval_max_interval_s,
125
+ )
126
+ else:
127
+ # Reset to default interval for active processing states
128
+ interval = config.interval_s
129
+
130
+ # Timeout reached
131
+ if logger:
132
+ logger.error(
133
+ "[VaultKit] Polling timed out",
134
+ extra={"request_id": request_id},
135
+ )
136
+
137
+ raise PollTimeoutError(
138
+ f"Request still queued after {config.timeout_s:.0f}s",
139
+ request_id=request_id,
140
+ )
@@ -0,0 +1,36 @@
1
+ from .base import VaultKitError
2
+ from .exceptions import (
3
+ ApprovalRequiredError,
4
+ DeniedError,
5
+ GrantExpiredError,
6
+ GrantRevokedError,
7
+ PolicyBundleRevokedError,
8
+ PollTimeoutError,
9
+ QueuedError,
10
+ ValidationError,
11
+ TransportError,
12
+ ServerError,
13
+ RateLimitError,
14
+ )
15
+
16
+ __all__ = [
17
+ # Base
18
+ "VaultKitError",
19
+
20
+ # Domain (core VaultKit behavior)
21
+ "DeniedError",
22
+ "ApprovalRequiredError",
23
+ "QueuedError",
24
+ "GrantExpiredError",
25
+ "GrantRevokedError",
26
+ "PolicyBundleRevokedError",
27
+ "PollTimeoutError",
28
+
29
+ # User / validation
30
+ "ValidationError",
31
+
32
+ # Transport / infra
33
+ "TransportError",
34
+ "ServerError",
35
+ "RateLimitError",
36
+ ]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional
5
+
6
+
7
+ @dataclass
8
+ class ErrorContext:
9
+ status_code: Optional[int] = None
10
+ request_id: Optional[str] = None
11
+ correlation_id: Optional[str] = None
12
+ raw: Optional[Any] = None
13
+
14
+
15
+ class VaultKitError(Exception):
16
+ """Base exception for VaultKit SDK."""
17
+
18
+ error_code: str = "vaultkit_error"
19
+ retryable: bool = False
20
+
21
+ def __init__(self, message: str, *, context: Optional[ErrorContext] = None):
22
+ super().__init__(message)
23
+ self.message = message
24
+ self.context = context or ErrorContext()
25
+
26
+ def __str__(self) -> str:
27
+ base = self.message
28
+ if self.context and self.context.correlation_id:
29
+ return f"{base} (correlation_id={self.context.correlation_id})"
30
+ return base
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Any
4
+
5
+ from .base import VaultKitError, ErrorContext
6
+
7
+
8
+ # generic categories
9
+
10
+ class ValidationError(VaultKitError):
11
+ error_code = "validation_error"
12
+
13
+
14
+ class AuthError(VaultKitError):
15
+ error_code = "auth_error"
16
+
17
+
18
+ class ForbiddenError(VaultKitError):
19
+ error_code = "forbidden"
20
+
21
+
22
+ class NotFoundError(VaultKitError):
23
+ error_code = "not_found"
24
+
25
+
26
+ class ConflictError(VaultKitError):
27
+ error_code = "conflict"
28
+
29
+
30
+ class RateLimitError(VaultKitError):
31
+ error_code = "rate_limited"
32
+ retryable = True
33
+
34
+
35
+ class TransportError(VaultKitError):
36
+ error_code = "transport_error"
37
+ retryable = True
38
+
39
+
40
+ class ServerError(VaultKitError):
41
+ error_code = "server_error"
42
+ retryable = True
43
+
44
+
45
+ class ClientError(VaultKitError):
46
+ error_code = "client_error"
47
+
48
+
49
+ # domain-specific (VaultKit)
50
+
51
+ class DeniedError(VaultKitError):
52
+ """
53
+ Raised when VaultKit policy denies a request.
54
+ Do not retry — denial is deterministic.
55
+ """
56
+
57
+ error_code = "policy_denied"
58
+ retryable = False
59
+
60
+ def __init__(
61
+ self,
62
+ message: str,
63
+ *,
64
+ policy_id: Optional[str] = None,
65
+ context: Optional[ErrorContext] = None,
66
+ ) -> None:
67
+ super().__init__(message, context=context)
68
+ self.policy_id = policy_id
69
+
70
+
71
+ class QueuedError(VaultKitError):
72
+ """
73
+ Raised when a request is queued and the caller chose not to poll.
74
+ """
75
+
76
+ error_code = "queued"
77
+
78
+ def __init__(
79
+ self,
80
+ message: str,
81
+ *,
82
+ request_id: Optional[str] = None,
83
+ context: Optional[ErrorContext] = None,
84
+ ) -> None:
85
+ super().__init__(message, context=context)
86
+ self.request_id = request_id
87
+
88
+
89
+ class ApprovalRequiredError(VaultKitError):
90
+ """
91
+ Raised when a request reaches pending_approval after polling.
92
+ """
93
+
94
+ error_code = "approval_required"
95
+
96
+ def __init__(
97
+ self,
98
+ message: str,
99
+ *,
100
+ request_id: Optional[str] = None,
101
+ context: Optional[ErrorContext] = None,
102
+ ) -> None:
103
+ super().__init__(message, context=context)
104
+ self.request_id = request_id
105
+
106
+
107
+ class GrantExpiredError(VaultKitError):
108
+ error_code = "grant_expired"
109
+
110
+ def __init__(
111
+ self,
112
+ message: str,
113
+ *,
114
+ grant_ref: Optional[str] = None,
115
+ context: Optional[ErrorContext] = None,
116
+ ) -> None:
117
+ super().__init__(message, context=context)
118
+ self.grant_ref = grant_ref
119
+
120
+
121
+ class GrantRevokedError(VaultKitError):
122
+ error_code = "grant_revoked"
123
+
124
+ def __init__(
125
+ self,
126
+ message: str,
127
+ *,
128
+ grant_ref: Optional[str] = None,
129
+ context: Optional[ErrorContext] = None,
130
+ ) -> None:
131
+ super().__init__(message, context=context)
132
+ self.grant_ref = grant_ref
133
+
134
+
135
+ class PolicyBundleRevokedError(VaultKitError):
136
+ """
137
+ Unrecoverable — escalate to a VaultKit administrator.
138
+ """
139
+
140
+ error_code = "policy_bundle_revoked"
141
+ retryable = False
142
+
143
+ def __init__(
144
+ self,
145
+ message: str,
146
+ *,
147
+ bundle_checksum: Optional[str] = None,
148
+ context: Optional[ErrorContext] = None,
149
+ ) -> None:
150
+ super().__init__(message, context=context)
151
+ self.bundle_checksum = bundle_checksum
152
+
153
+
154
+ class PollTimeoutError(VaultKitError):
155
+ """
156
+ Polling exceeded timeout. Request may still be processing.
157
+ """
158
+
159
+ error_code = "poll_timeout"
160
+ retryable = True # caller can retry polling
161
+
162
+ def __init__(
163
+ self,
164
+ message: str,
165
+ *,
166
+ request_id: Optional[str] = None,
167
+ context: Optional[ErrorContext] = None,
168
+ ) -> None:
169
+ super().__init__(message, context=context)
170
+ self.request_id = request_id
171
+
172
+
173
+ # HTTP mapping
174
+
175
+ def map_http_error(status_code: int, message: str, *, context: ErrorContext) -> VaultKitError:
176
+ raw = context.raw if isinstance(context.raw, dict) else {}
177
+
178
+ # future-proof: allow backend to send structured error types
179
+ error_type = raw.get("type") if isinstance(raw, dict) else None
180
+
181
+ if status_code == 400:
182
+ return ValidationError(message, context=context)
183
+
184
+ if status_code == 401:
185
+ return AuthError(message, context=context)
186
+
187
+ if status_code == 403:
188
+ if error_type == "policy_denied":
189
+ return DeniedError(
190
+ message,
191
+ policy_id=raw.get("policy_id"),
192
+ context=context,
193
+ )
194
+ return ForbiddenError(message, context=context)
195
+
196
+ if status_code == 404:
197
+ return NotFoundError(message, context=context)
198
+
199
+ if status_code == 409:
200
+ return ConflictError(message, context=context)
201
+
202
+ if status_code == 429:
203
+ return RateLimitError(message, context=context)
204
+
205
+ if 400 <= status_code < 500:
206
+ return ClientError(message, context=context)
207
+
208
+ if 500 <= status_code:
209
+ return ServerError(message, context=context)
210
+
211
+ return VaultKitError(message, context=context)
@@ -0,0 +1,11 @@
1
+ from .dataset_info import DatasetInfo
2
+ from .dataset_schema import DatasetSchema
3
+ from .fetch_result import FetchResult
4
+ from .query_result import QueryResult
5
+
6
+ __all__ = [
7
+ "QueryResult",
8
+ "FetchResult",
9
+ "DatasetInfo",
10
+ "DatasetSchema",
11
+ ]
@@ -0,0 +1,25 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class DatasetInfo:
7
+ dataset: str
8
+ datasource: str
9
+ visibility: Optional[str] = None
10
+ correlation_id: Optional[str] = None
11
+
12
+ @classmethod
13
+ def from_dict(cls, data):
14
+ dataset = data.get("name") or data.get("dataset")
15
+ datasource = data.get("datasource")
16
+
17
+ if not dataset or not datasource:
18
+ raise ValueError("DatasetInfo requires 'name'/'dataset' and 'datasource'")
19
+
20
+ return cls(
21
+ dataset=dataset,
22
+ datasource=datasource,
23
+ visibility=data.get("visibility"),
24
+ correlation_id=data.get("correlation_id"),
25
+ )