magickmind 0.1.1__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.
Files changed (43) hide show
  1. magick_mind/__init__.py +39 -0
  2. magick_mind/auth/__init__.py +9 -0
  3. magick_mind/auth/base.py +46 -0
  4. magick_mind/auth/email_password.py +268 -0
  5. magick_mind/client.py +188 -0
  6. magick_mind/config.py +28 -0
  7. magick_mind/exceptions.py +107 -0
  8. magick_mind/http/__init__.py +5 -0
  9. magick_mind/http/client.py +313 -0
  10. magick_mind/models/__init__.py +17 -0
  11. magick_mind/models/auth.py +30 -0
  12. magick_mind/models/common.py +32 -0
  13. magick_mind/models/errors.py +73 -0
  14. magick_mind/models/v1/__init__.py +83 -0
  15. magick_mind/models/v1/api_keys.py +115 -0
  16. magick_mind/models/v1/artifact.py +151 -0
  17. magick_mind/models/v1/chat.py +104 -0
  18. magick_mind/models/v1/corpus.py +82 -0
  19. magick_mind/models/v1/end_user.py +75 -0
  20. magick_mind/models/v1/history.py +94 -0
  21. magick_mind/models/v1/mindspace.py +130 -0
  22. magick_mind/models/v1/model.py +25 -0
  23. magick_mind/models/v1/project.py +73 -0
  24. magick_mind/realtime/__init__.py +5 -0
  25. magick_mind/realtime/client.py +202 -0
  26. magick_mind/realtime/handler.py +122 -0
  27. magick_mind/resources/README.md +201 -0
  28. magick_mind/resources/__init__.py +42 -0
  29. magick_mind/resources/base.py +31 -0
  30. magick_mind/resources/v1/__init__.py +19 -0
  31. magick_mind/resources/v1/api_keys.py +181 -0
  32. magick_mind/resources/v1/artifact.py +287 -0
  33. magick_mind/resources/v1/chat.py +120 -0
  34. magick_mind/resources/v1/corpus.py +156 -0
  35. magick_mind/resources/v1/end_user.py +181 -0
  36. magick_mind/resources/v1/history.py +88 -0
  37. magick_mind/resources/v1/mindspace.py +331 -0
  38. magick_mind/resources/v1/model.py +19 -0
  39. magick_mind/resources/v1/project.py +155 -0
  40. magick_mind/routes.py +76 -0
  41. magickmind-0.1.1.dist-info/METADATA +593 -0
  42. magickmind-0.1.1.dist-info/RECORD +43 -0
  43. magickmind-0.1.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,39 @@
1
+ """
2
+ Magick Mind SDK - Python client for Bifrost Magick Mind AI platform.
3
+
4
+ Simple, powerful SDK for authentication and interaction with the Magick Mind API.
5
+ """
6
+
7
+ from magick_mind.client import MagickMind
8
+ from magick_mind.exceptions import (
9
+ AuthenticationError,
10
+ MagickMindError,
11
+ ProblemDetailsException,
12
+ RateLimitError,
13
+ TokenExpiredError,
14
+ ValidationError,
15
+ )
16
+ from magick_mind.models.v1 import (
17
+ ChatPayload,
18
+ ChatSendRequest,
19
+ ChatSendResponse,
20
+ ChatHistoryMessage,
21
+ HistoryResponse,
22
+ )
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "MagickMind",
28
+ "AuthenticationError",
29
+ "MagickMindError",
30
+ "ProblemDetailsException",
31
+ "RateLimitError",
32
+ "TokenExpiredError",
33
+ "ValidationError",
34
+ "ChatSendRequest",
35
+ "ChatPayload",
36
+ "ChatSendResponse",
37
+ "ChatHistoryMessage",
38
+ "HistoryResponse",
39
+ ]
@@ -0,0 +1,9 @@
1
+ """Authentication module for Magick Mind SDK."""
2
+
3
+ from magick_mind.auth.base import AuthProvider
4
+ from magick_mind.auth.email_password import EmailPasswordAuth
5
+
6
+ __all__ = [
7
+ "AuthProvider",
8
+ "EmailPasswordAuth",
9
+ ]
@@ -0,0 +1,46 @@
1
+ """Base authentication provider interface."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict
5
+
6
+
7
+ class AuthProvider(ABC):
8
+ """Base class for authentication providers."""
9
+
10
+ @abstractmethod
11
+ def get_headers(self) -> Dict[str, str]:
12
+ """
13
+ Get authentication headers for API requests.
14
+
15
+ Returns:
16
+ Dictionary of HTTP headers to include in requests
17
+ """
18
+ pass
19
+
20
+ @abstractmethod
21
+ def is_authenticated(self) -> bool:
22
+ """
23
+ Check if the provider is currently authenticated.
24
+
25
+ Returns:
26
+ True if authenticated, False otherwise
27
+ """
28
+ pass
29
+
30
+ @abstractmethod
31
+ def get_token(self) -> str:
32
+ """
33
+ Get the raw access token.
34
+ Should handle refresh if needed.
35
+
36
+ Returns:
37
+ Raw access token string
38
+ """
39
+ pass
40
+
41
+ def refresh_if_needed(self) -> None:
42
+ """
43
+ Refresh authentication credentials if needed.
44
+ Override this method in subclasses that support token refresh.
45
+ """
46
+ pass
@@ -0,0 +1,268 @@
1
+ """Email/password authentication provider."""
2
+
3
+ import time
4
+ from typing import Dict, Optional
5
+ import httpx
6
+
7
+ from magick_mind.auth.base import AuthProvider
8
+ from magick_mind.exceptions import AuthenticationError, TokenExpiredError
9
+ from magick_mind.models.auth import LoginRequest, RefreshRequest, TokenResponse
10
+ from magick_mind.routes import Routes
11
+
12
+
13
+ import logging
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class EmailPasswordAuth(AuthProvider):
19
+ """
20
+ Email/password authentication using bifrost's /v1/auth/login endpoint.
21
+
22
+ Automatically handles token refresh when the access token expires.
23
+
24
+ Example:
25
+ auth = EmailPasswordAuth(
26
+ email="user@example.com",
27
+ password="your_password",
28
+ base_url="https://bifrost.example.com"
29
+ )
30
+ # Login happens automatically on first request
31
+ headers = auth.get_headers()
32
+ """
33
+
34
+ def __init__(self, email: str, password: str, base_url: str, timeout: float = 30.0):
35
+ """
36
+ Initialize email/password authentication.
37
+
38
+ Args:
39
+ email: User email address
40
+ password: User password
41
+ base_url: Base URL of the Bifrost API
42
+ timeout: Request timeout in seconds
43
+ """
44
+ if not email or not password:
45
+ raise ValueError("Email and password are required")
46
+
47
+ self.email = email
48
+ self.password = password
49
+ self.base_url = base_url.rstrip("/")
50
+ self.timeout = timeout
51
+
52
+ # Token storage
53
+ self._access_token: Optional[str] = None
54
+ self._refresh_token: Optional[str] = None
55
+ self._token_expires_at: float = 0.0
56
+ self._refresh_expires_at: float = 0.0
57
+
58
+ def get_headers(self) -> Dict[str, str]:
59
+ """
60
+ Get authorization header with access token.
61
+ Automatically logs in or refreshes token if needed.
62
+ """
63
+ self.refresh_if_needed()
64
+
65
+ if not self._access_token:
66
+ raise AuthenticationError(
67
+ "Not authenticated. Failed to obtain access token."
68
+ )
69
+
70
+ return {"Authorization": f"Bearer {self._access_token}"}
71
+
72
+ def is_authenticated(self) -> bool:
73
+ """Check if currently authenticated with a valid token."""
74
+ return bool(self._access_token) and time.time() < self._token_expires_at
75
+
76
+ def get_token(self) -> str:
77
+ """Get raw access token, refreshing if needed."""
78
+ self.refresh_if_needed()
79
+ if not self._access_token:
80
+ raise AuthenticationError(
81
+ "Not authenticated. Failed to obtain access token."
82
+ )
83
+ return self._access_token
84
+
85
+ def refresh_if_needed(self) -> None:
86
+ """
87
+ Refresh authentication if needed.
88
+
89
+ Logic:
90
+ 1. If no access token, perform login
91
+ 2. If access token expired but refresh token valid, refresh
92
+ 3. If both expired, perform login
93
+ """
94
+ current_time = time.time()
95
+
96
+ # No token yet - need to login
97
+ if not self._access_token:
98
+ self._login()
99
+ return
100
+
101
+ # Access token still valid
102
+ if current_time < self._token_expires_at:
103
+ return
104
+
105
+ # Access token expired - try refresh if refresh token is valid
106
+ if self._refresh_token and current_time < self._refresh_expires_at:
107
+ try:
108
+ self._refresh()
109
+ return
110
+ except Exception:
111
+ # Refresh failed, fall back to login
112
+ pass
113
+
114
+ # Refresh not available or failed - do full login
115
+ self._login()
116
+
117
+ def _login(self) -> None:
118
+ """Perform login to get initial tokens."""
119
+ login_url = f"{self.base_url}{Routes.AUTH_LOGIN}"
120
+
121
+ payload = LoginRequest(email=self.email, password=self.password)
122
+
123
+ try:
124
+ with httpx.Client(timeout=self.timeout) as client:
125
+ response = client.post(login_url, json=payload.model_dump())
126
+ response.raise_for_status()
127
+
128
+ # Parse and validate response (flat TokenResponse, no wrapper)
129
+ data = TokenResponse(**response.json())
130
+ self._store_tokens(data)
131
+
132
+ except httpx.HTTPStatusError as e:
133
+ if e.response.status_code == 401:
134
+ raise AuthenticationError("Invalid email or password", status_code=401)
135
+ raise AuthenticationError(
136
+ f"Login failed: {str(e)}", status_code=e.response.status_code
137
+ )
138
+ except httpx.RequestError as e:
139
+ raise AuthenticationError(f"Network error during login: {str(e)}")
140
+
141
+ def _refresh(self) -> None:
142
+ """Refresh the access token using the refresh token."""
143
+ # Note: Bifrost might have a refresh endpoint, but if not,
144
+ # we'll just re-login. This can be updated when the endpoint is available.
145
+ refresh_url = f"{self.base_url}{Routes.AUTH_REFRESH}"
146
+
147
+ if not self._refresh_token:
148
+ raise TokenExpiredError("No refresh token available")
149
+
150
+ try:
151
+ refresh_req = RefreshRequest(refresh_token=self._refresh_token)
152
+ with httpx.Client(timeout=self.timeout) as client:
153
+ response = client.post(refresh_url, json=refresh_req.model_dump())
154
+ response.raise_for_status()
155
+
156
+ # Parse and validate response (flat TokenResponse, no wrapper)
157
+ data = TokenResponse(**response.json())
158
+ self._store_tokens(data)
159
+ except httpx.HTTPStatusError as e:
160
+ if e.response.status_code == 401:
161
+ raise TokenExpiredError("Refresh token expired or invalid")
162
+ raise AuthenticationError(
163
+ f"Token refresh failed: {str(e)}", status_code=e.response.status_code
164
+ )
165
+ except httpx.RequestError as e:
166
+ raise AuthenticationError(f"Network error during token refresh: {str(e)}")
167
+
168
+ def _store_tokens(self, token_data: TokenResponse) -> None:
169
+ """Store tokens and calculate expiration times."""
170
+ current_time = time.time()
171
+
172
+ # Access via attributes (Pydantic model)
173
+ self._access_token = token_data.access_token
174
+ self._refresh_token = token_data.refresh_token
175
+
176
+ # Add buffer of 10 seconds to avoid edge cases
177
+ # Default fallback values handled by Pydantic model structure if strict
178
+ # But here fields are required except options.
179
+ # API should ensure these exist.
180
+ expires_in = token_data.expires_in - 10
181
+ self._token_expires_at = current_time + max(expires_in, 0)
182
+
183
+ refresh_expires_in = token_data.refresh_expires_in - 10
184
+ self._refresh_expires_at = current_time + max(refresh_expires_in, 0)
185
+
186
+ async def get_token_async(self) -> str:
187
+ """Get raw access token asynchronously, refreshing if needed."""
188
+ await self.refresh_if_needed_async()
189
+ if not self._access_token:
190
+ raise AuthenticationError(
191
+ "Not authenticated. Failed to obtain access token."
192
+ )
193
+ return self._access_token
194
+
195
+ async def refresh_if_needed_async(self) -> None:
196
+ """Async version of refresh_if_needed."""
197
+ current_time = time.time()
198
+
199
+ if not self._access_token:
200
+ await self._login_async()
201
+ return
202
+
203
+ if current_time < self._token_expires_at:
204
+ return
205
+
206
+ if self._refresh_token and current_time < self._refresh_expires_at:
207
+ try:
208
+ await self._refresh_async()
209
+ return
210
+ except Exception:
211
+ pass
212
+
213
+ await self._login_async()
214
+
215
+ async def _login_async(self) -> None:
216
+ """Async login."""
217
+ login_url = f"{self.base_url}{Routes.AUTH_LOGIN}"
218
+ payload = LoginRequest(email=self.email, password=self.password)
219
+
220
+ try:
221
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
222
+ response = await client.post(login_url, json=payload.model_dump())
223
+ response.raise_for_status()
224
+
225
+ # Parse and validate response (flat TokenResponse, no wrapper)
226
+ data = TokenResponse(**response.json())
227
+ self._store_tokens(data)
228
+ except httpx.HTTPStatusError as e:
229
+ if e.response.status_code == 401:
230
+ raise AuthenticationError("Invalid email or password", status_code=401)
231
+ raise AuthenticationError(
232
+ f"Login failed: {str(e)}", status_code=e.response.status_code
233
+ )
234
+ except httpx.RequestError as e:
235
+ raise AuthenticationError(f"Network error during login: {str(e)}")
236
+
237
+ async def _refresh_async(self) -> None:
238
+ """Async refresh."""
239
+ refresh_url = f"{self.base_url}{Routes.AUTH_REFRESH}"
240
+ if not self._refresh_token:
241
+ raise TokenExpiredError("No refresh token available")
242
+
243
+ try:
244
+ refresh_req = RefreshRequest(refresh_token=self._refresh_token)
245
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
246
+ response = await client.post(refresh_url, json=refresh_req.model_dump())
247
+ response.raise_for_status()
248
+
249
+ # Parse and validate response (flat TokenResponse, no wrapper)
250
+ data = TokenResponse(**response.json())
251
+ self._store_tokens(data)
252
+ except httpx.HTTPStatusError as e:
253
+ if e.response.status_code == 401:
254
+ raise TokenExpiredError("Refresh token expired or invalid")
255
+ raise AuthenticationError(
256
+ f"Token refresh failed: {str(e)}", status_code=e.response.status_code
257
+ )
258
+ except httpx.RequestError as e:
259
+ raise AuthenticationError(f"Network error during token refresh: {str(e)}")
260
+
261
+ async def get_headers_async(self) -> Dict[str, str]:
262
+ """
263
+ Async version of get_headers.
264
+ """
265
+ await self.refresh_if_needed_async()
266
+ if not self._access_token:
267
+ raise AuthenticationError("Not authenticated.")
268
+ return {"Authorization": f"Bearer {self._access_token}"}
magick_mind/client.py ADDED
@@ -0,0 +1,188 @@
1
+ """Main Magick Mind SDK client."""
2
+
3
+ from typing import Optional
4
+
5
+ from magick_mind.auth import AuthProvider, EmailPasswordAuth
6
+ from magick_mind.config import SDKConfig
7
+ from magick_mind.http import HTTPClient
8
+ from magick_mind.realtime import RealtimeClient
9
+
10
+
11
+ class MagickMind:
12
+ """
13
+ Main client for the Magick Mind SDK.
14
+
15
+ This is the primary interface for interacting with the Bifrost Magick Mind API.
16
+
17
+ Provides:
18
+ - Authentication (email/password with JWT, automatic refresh)
19
+ - Typed resources (v1.chat, etc.) with Pydantic validation
20
+ - HTTP client for direct API access
21
+ - Realtime client for WebSocket connections (async)
22
+
23
+ Example:
24
+ # Initialize client
25
+ client = MagickMind(
26
+ email="user@example.com",
27
+ password="your_password",
28
+ base_url="https://bifrost.example.com"
29
+ )
30
+
31
+ # Use typed resources (recommended)
32
+ response = client.v1.chat.send(
33
+ api_key="sk-...",
34
+ mindspace_id="mind-123",
35
+ message="Hello!",
36
+ sender_id="user-456"
37
+ )
38
+ print(response.content.content) # AI response
39
+
40
+ # Or use convenience alias
41
+ response = client.chat.send(...)
42
+
43
+ # Use HTTP client directly for experimental endpoints
44
+ response = client.http.post("/experimental/endpoint", json={...})
45
+
46
+ # Use Realtime client (in async context)
47
+ async def main():
48
+ await client.realtime.connect(events=MyHandler())
49
+ await client.realtime.subscribe(target_user_id="user-456")
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ base_url: str,
55
+ email: str,
56
+ password: str,
57
+ timeout: float = 30.0,
58
+ verify_ssl: bool = True,
59
+ ws_endpoint: Optional[str] = None,
60
+ ):
61
+ """
62
+ Initialize the Magick Mind client.
63
+
64
+ Args:
65
+ base_url: Base URL of the Bifrost API (e.g., https://bifrost.example.com)
66
+ email: User email for authentication
67
+ password: User password for authentication
68
+ timeout: Request timeout in seconds
69
+ verify_ssl: Whether to verify SSL certificates
70
+ ws_endpoint: WebSocket URL (Required for .realtime usage)
71
+ """
72
+ if not email or not password:
73
+ raise ValueError("Email and password are required for authentication")
74
+
75
+ # Create configuration
76
+ self.config = SDKConfig(
77
+ base_url=base_url,
78
+ timeout=timeout,
79
+ verify_ssl=verify_ssl,
80
+ ws_endpoint=ws_endpoint,
81
+ )
82
+
83
+ # Create authentication provider (email/password with JWT)
84
+ self.auth: AuthProvider = EmailPasswordAuth(
85
+ email=email, password=password, base_url=base_url, timeout=timeout
86
+ )
87
+
88
+ # Create HTTP client (private, accessed via property)
89
+ self._http = HTTPClient(config=self.config, auth=self.auth)
90
+
91
+ # Create Realtime client (private, accessed via property)
92
+ self._realtime = RealtimeClient(auth=self.auth, ws_url=ws_endpoint)
93
+
94
+ # Initialize typed resources
95
+ from magick_mind.resources import V1Resources
96
+
97
+ self.v1 = V1Resources(self._http)
98
+
99
+ # Convenience alias for default version
100
+ self.chat = self.v1.chat
101
+ self.mindspace = self.v1.mindspace
102
+ self.models = self.v1.models
103
+
104
+ @property
105
+ def http(self) -> HTTPClient:
106
+ """
107
+ Low-level HTTP client bound to this MagickMind instance.
108
+
109
+ Features:
110
+ - Uses same base_url and configuration
111
+ - Automatically attaches authentication tokens
112
+ - Applies centralized error mapping
113
+ - Auto-refreshes expired tokens
114
+
115
+ Intended for:
116
+ - Bifrost developers testing new endpoints
117
+ - Power users needing direct API access
118
+ - Experimenting with endpoints before implementing resources
119
+
120
+ Example:
121
+ # Test a new endpoint directly
122
+ response = client.http.post(
123
+ "/experimental/new-feature",
124
+ json={"test": "data"}
125
+ )
126
+
127
+ # Quick one-off calls
128
+ response = client.http.get("/v1/status")
129
+
130
+ Returns:
131
+ HTTPClient: Configured HTTP client instance
132
+ """
133
+ return self._http
134
+
135
+ @property
136
+ def realtime(self) -> RealtimeClient:
137
+ """
138
+ Realtime WebSocket client.
139
+
140
+ Note: This client is ASYNC. You must use it within an async context.
141
+
142
+ Features:
143
+ - Authenticated WebSocket connection
144
+ - RPC subscriptions (Bifrost specific)
145
+ - Handling disconnects/reconnects (via centrifuge-python)
146
+
147
+ Returns:
148
+ RealtimeClient: Configured async realtime client
149
+ """
150
+ return self._realtime
151
+
152
+ def test_connection(self) -> bool:
153
+ """Test the connection to the API."""
154
+ try:
155
+ # This assumes there's a health check or similar endpoint
156
+ response = self.http.get("/health")
157
+ return response.get("success", False)
158
+ except Exception:
159
+ return False
160
+
161
+ def is_authenticated(self) -> bool:
162
+ """
163
+ Check if the client is authenticated.
164
+
165
+ Returns:
166
+ True if authenticated, False otherwise
167
+ """
168
+ """Check if the client is authenticated."""
169
+ return self.auth.is_authenticated()
170
+
171
+ def close(self) -> None:
172
+ """Close the client and cleanup resources."""
173
+ self._http.close()
174
+ # Realtime client might need async close?
175
+ # But close() here is typically sync.
176
+ # User should probably manage realtime lifecycle themselves if async.
177
+
178
+ def __enter__(self):
179
+ """Context manager entry."""
180
+ return self
181
+
182
+ def __exit__(self, exc_type, exc_val, exc_tb):
183
+ """Context manager exit."""
184
+ self.close()
185
+
186
+ def __repr__(self) -> str:
187
+ """String representation of the client."""
188
+ return f"MagickMind(base_url='{self.config.base_url}', auth='EmailPassword')"
magick_mind/config.py ADDED
@@ -0,0 +1,28 @@
1
+ """Configuration models for Magick Mind SDK."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class SDKConfig:
9
+ """Configuration for the Magick Mind SDK."""
10
+
11
+ base_url: str
12
+ """Base URL for the Bifrost API (e.g., https://bifrost.example.com)"""
13
+
14
+ timeout: float = 30.0
15
+ """Request timeout in seconds"""
16
+
17
+ max_retries: int = 3
18
+ """Maximum number of retries for failed requests"""
19
+
20
+ verify_ssl: bool = True
21
+ """Whether to verify SSL certificates"""
22
+
23
+ ws_endpoint: Optional[str] = None
24
+ """Explicit WebSocket endpoint URL (optional)"""
25
+
26
+ def normalized_base_url(self) -> str:
27
+ """Return base URL without trailing slash."""
28
+ return self.base_url.rstrip("/")
@@ -0,0 +1,107 @@
1
+ """Custom exceptions for Magick Mind SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from magick_mind.models.errors import ProblemDetails
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class MagickMindError(Exception):
13
+ """Base exception for all Magick Mind SDK errors."""
14
+
15
+ def __init__(self, message: str, status_code: int | None = None):
16
+ self.message = message
17
+ self.status_code = status_code
18
+ super().__init__(self.message)
19
+
20
+
21
+ class AuthenticationError(MagickMindError):
22
+ """Raised when authentication fails."""
23
+
24
+ pass
25
+
26
+
27
+ class TokenExpiredError(AuthenticationError):
28
+ """Raised when a token has expired."""
29
+
30
+ pass
31
+
32
+
33
+ class RateLimitError(MagickMindError):
34
+ """Raised when rate limit is exceeded."""
35
+
36
+ pass
37
+
38
+
39
+ class ProblemDetailsException(MagickMindError):
40
+ """RFC 7807 Problem Details error from Bifrost."""
41
+
42
+ def __init__(
43
+ self,
44
+ problem: ProblemDetails,
45
+ raw_response: dict | None = None,
46
+ ):
47
+ self.type_uri = problem.type
48
+ self.title = problem.title
49
+ self.status = problem.status
50
+ self.detail = problem.detail
51
+ self.instance = problem.instance
52
+ self.request_id = problem.request_id
53
+ self.validation_errors = problem.errors
54
+ self.problem = problem # Full Pydantic model
55
+
56
+ # Log with request_id for tracing
57
+ logger.debug(
58
+ "API error: %s [%d] %s (request_id=%s, instance=%s)",
59
+ self.title,
60
+ self.status,
61
+ self.detail,
62
+ self.request_id or "none",
63
+ self.instance or "none",
64
+ )
65
+
66
+ super().__init__(self.detail, status_code=self.status)
67
+ self.response_data = raw_response
68
+
69
+ def __str__(self) -> str:
70
+ msg = f"[{self.status}] {self.title}: {self.detail}"
71
+ if self.request_id:
72
+ msg += f" (request_id: {self.request_id})"
73
+ if self.validation_errors:
74
+ msg += f"\nValidation errors ({len(self.validation_errors)}):"
75
+ for err in self.validation_errors:
76
+ msg += f"\n - {err.field}: {err.message}"
77
+ return msg
78
+
79
+ def __repr__(self) -> str:
80
+ return f"ProblemDetailsException(status={self.status}, title={self.title!r}, request_id={self.request_id!r})"
81
+
82
+
83
+ class ValidationError(ProblemDetailsException):
84
+ """400 Bad Request with field-level validation errors."""
85
+
86
+ def __init__(self, problem: ProblemDetails, raw_response: dict | None = None):
87
+ if problem.status != 400:
88
+ raise ValueError(
89
+ f"ValidationError must have status 400, got {problem.status}"
90
+ )
91
+ if not problem.errors:
92
+ logger.warning("ValidationError created without field errors")
93
+ super().__init__(problem, raw_response)
94
+
95
+ def get_field_errors(self) -> dict[str, list[str]]:
96
+ """
97
+ Get errors grouped by field name for UI display.
98
+
99
+ Note: Returns simplified dict[field, messages]. Access validation_errors
100
+ directly if you need error codes (e.g., "required", "invalid_format").
101
+ """
102
+ errors_by_field: dict[str, list[str]] = {}
103
+ for err in self.validation_errors:
104
+ if err.field not in errors_by_field:
105
+ errors_by_field[err.field] = []
106
+ errors_by_field[err.field].append(err.message)
107
+ return errors_by_field
@@ -0,0 +1,5 @@
1
+ """HTTP client module for Magick Mind SDK."""
2
+
3
+ from magick_mind.http.client import HTTPClient
4
+
5
+ __all__ = ["HTTPClient"]