miso-client 0.1.0__py3-none-any.whl → 0.2.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.

Potentially problematic release.


This version of miso-client might be problematic. Click here for more details.

@@ -10,9 +10,9 @@ from typing import Any, Set
10
10
 
11
11
  class DataMasker:
12
12
  """Static class for masking sensitive data."""
13
-
13
+
14
14
  MASKED_VALUE = "***MASKED***"
15
-
15
+
16
16
  # Set of sensitive field names (normalized)
17
17
  _sensitive_fields: Set[str] = {
18
18
  "password",
@@ -37,58 +37,58 @@ class DataMasker:
37
37
  "privatekey",
38
38
  "secretkey",
39
39
  }
40
-
40
+
41
41
  @classmethod
42
42
  def is_sensitive_field(cls, key: str) -> bool:
43
43
  """
44
44
  Check if a field name indicates sensitive data.
45
-
45
+
46
46
  Args:
47
47
  key: Field name to check
48
-
48
+
49
49
  Returns:
50
50
  True if field is sensitive, False otherwise
51
51
  """
52
52
  # Normalize key: lowercase and remove underscores/hyphens
53
53
  normalized_key = key.lower().replace("_", "").replace("-", "")
54
-
54
+
55
55
  # Check exact match
56
56
  if normalized_key in cls._sensitive_fields:
57
57
  return True
58
-
58
+
59
59
  # Check if field contains sensitive keywords
60
60
  for sensitive_field in cls._sensitive_fields:
61
61
  if sensitive_field in normalized_key:
62
62
  return True
63
-
63
+
64
64
  return False
65
-
65
+
66
66
  @classmethod
67
67
  def mask_sensitive_data(cls, data: Any) -> Any:
68
68
  """
69
69
  Mask sensitive data in objects, arrays, or primitives.
70
-
70
+
71
71
  Returns a masked copy without modifying the original.
72
72
  Recursively processes nested objects and arrays.
73
-
73
+
74
74
  Args:
75
75
  data: Data to mask (dict, list, or primitive)
76
-
76
+
77
77
  Returns:
78
78
  Masked copy of the data
79
79
  """
80
80
  # Handle null and undefined
81
81
  if data is None:
82
82
  return data
83
-
83
+
84
84
  # Handle primitives (string, number, boolean)
85
85
  if not isinstance(data, (dict, list)):
86
86
  return data
87
-
87
+
88
88
  # Handle arrays
89
89
  if isinstance(data, list):
90
90
  return [cls.mask_sensitive_data(item) for item in data]
91
-
91
+
92
92
  # Handle objects/dicts
93
93
  masked: dict[str, Any] = {}
94
94
  for key, value in data.items():
@@ -101,49 +101,49 @@ class DataMasker:
101
101
  else:
102
102
  # Keep non-sensitive value as-is
103
103
  masked[key] = value
104
-
104
+
105
105
  return masked
106
-
106
+
107
107
  @classmethod
108
108
  def mask_value(cls, value: str, show_first: int = 0, show_last: int = 0) -> str:
109
109
  """
110
110
  Mask specific value (useful for masking individual strings).
111
-
111
+
112
112
  Args:
113
113
  value: String value to mask
114
114
  show_first: Number of characters to show at the start
115
115
  show_last: Number of characters to show at the end
116
-
116
+
117
117
  Returns:
118
118
  Masked string value
119
119
  """
120
120
  if not value or len(value) <= show_first + show_last:
121
121
  return cls.MASKED_VALUE
122
-
122
+
123
123
  first = value[:show_first] if show_first > 0 else ""
124
124
  last = value[-show_last:] if show_last > 0 else ""
125
125
  masked_length = max(8, len(value) - show_first - show_last)
126
126
  masked = "*" * masked_length
127
-
127
+
128
128
  return f"{first}{masked}{last}"
129
-
129
+
130
130
  @classmethod
131
131
  def contains_sensitive_data(cls, data: Any) -> bool:
132
132
  """
133
133
  Check if data contains sensitive information.
134
-
134
+
135
135
  Args:
136
136
  data: Data to check
137
-
137
+
138
138
  Returns:
139
139
  True if data contains sensitive fields, False otherwise
140
140
  """
141
141
  if data is None or not isinstance(data, (dict, list)):
142
142
  return False
143
-
143
+
144
144
  if isinstance(data, list):
145
145
  return any(cls.contains_sensitive_data(item) for item in data)
146
-
146
+
147
147
  # Check object keys
148
148
  for key, value in data.items():
149
149
  if cls.is_sensitive_field(key):
@@ -151,6 +151,5 @@ class DataMasker:
151
151
  if isinstance(value, (dict, list)):
152
152
  if cls.contains_sensitive_data(value):
153
153
  return True
154
-
155
- return False
156
154
 
155
+ return False
@@ -6,21 +6,23 @@ with the Miso Controller, including automatic client token management,
6
6
  retry logic, and error handling.
7
7
  """
8
8
 
9
- import httpx
10
9
  import asyncio
11
10
  from datetime import datetime, timedelta
12
- from typing import Any, Dict, Optional, Literal
13
- from ..models.config import MisoClientConfig, ClientTokenResponse
14
- from ..errors import MisoClientError, AuthenticationError, ConnectionError
11
+ from typing import Any, Dict, Literal, Optional
12
+
13
+ import httpx
14
+
15
+ from ..errors import AuthenticationError, ConnectionError, MisoClientError
16
+ from ..models.config import ClientTokenResponse, MisoClientConfig
15
17
 
16
18
 
17
19
  class HttpClient:
18
20
  """HTTP client for Miso Controller communication with automatic client token management."""
19
-
21
+
20
22
  def __init__(self, config: MisoClientConfig):
21
23
  """
22
24
  Initialize HTTP client with configuration.
23
-
25
+
24
26
  Args:
25
27
  config: MisoClient configuration
26
28
  """
@@ -29,7 +31,7 @@ class HttpClient:
29
31
  self.client_token: Optional[str] = None
30
32
  self.token_expires_at: Optional[datetime] = None
31
33
  self.token_refresh_lock = asyncio.Lock()
32
-
34
+
33
35
  async def _initialize_client(self):
34
36
  """Initialize HTTP client if not already initialized."""
35
37
  if self.client is None:
@@ -38,59 +40,59 @@ class HttpClient:
38
40
  timeout=30.0,
39
41
  headers={
40
42
  "Content-Type": "application/json",
41
- }
43
+ },
42
44
  )
43
-
45
+
44
46
  async def _get_client_token(self) -> str:
45
47
  """
46
48
  Get client token, fetching if needed.
47
-
49
+
48
50
  Proactively refreshes if token will expire within 60 seconds.
49
-
51
+
50
52
  Returns:
51
53
  Client token string
52
-
54
+
53
55
  Raises:
54
56
  AuthenticationError: If token fetch fails
55
57
  """
56
58
  await self._initialize_client()
57
-
59
+
58
60
  now = datetime.now()
59
-
61
+
60
62
  # If token exists and not expired (with 60s buffer for proactive refresh), return it
61
63
  if (
62
- self.client_token and
63
- self.token_expires_at and
64
- self.token_expires_at > now + timedelta(seconds=60)
64
+ self.client_token
65
+ and self.token_expires_at
66
+ and self.token_expires_at > now + timedelta(seconds=60)
65
67
  ):
66
68
  assert self.client_token is not None
67
69
  return self.client_token
68
-
70
+
69
71
  # Acquire lock to prevent concurrent token fetches
70
72
  async with self.token_refresh_lock:
71
73
  # Double-check after acquiring lock
72
74
  if (
73
- self.client_token and
74
- self.token_expires_at and
75
- self.token_expires_at > now + timedelta(seconds=60)
75
+ self.client_token
76
+ and self.token_expires_at
77
+ and self.token_expires_at > now + timedelta(seconds=60)
76
78
  ):
77
79
  assert self.client_token is not None
78
80
  return self.client_token
79
-
81
+
80
82
  # Fetch new token
81
83
  await self._fetch_client_token()
82
84
  assert self.client_token is not None
83
85
  return self.client_token
84
-
86
+
85
87
  async def _fetch_client_token(self) -> None:
86
88
  """
87
89
  Fetch client token from controller.
88
-
90
+
89
91
  Raises:
90
92
  AuthenticationError: If token fetch fails
91
93
  """
92
94
  await self._initialize_client()
93
-
95
+
94
96
  try:
95
97
  # Use a temporary client to avoid interceptor recursion
96
98
  temp_client = httpx.AsyncClient(
@@ -100,67 +102,67 @@ class HttpClient:
100
102
  "Content-Type": "application/json",
101
103
  "x-client-id": self.config.client_id,
102
104
  "x-client-secret": self.config.client_secret,
103
- }
105
+ },
104
106
  )
105
-
107
+
106
108
  response = await temp_client.post("/api/auth/token")
107
109
  await temp_client.aclose()
108
-
110
+
109
111
  if response.status_code != 200:
110
112
  raise AuthenticationError(
111
113
  f"Failed to get client token: HTTP {response.status_code}",
112
- status_code=response.status_code
114
+ status_code=response.status_code,
113
115
  )
114
-
116
+
115
117
  data = response.json()
116
118
  token_response = ClientTokenResponse(**data)
117
-
119
+
118
120
  if not token_response.success or not token_response.token:
119
121
  raise AuthenticationError("Failed to get client token: Invalid response")
120
-
122
+
121
123
  self.client_token = token_response.token
122
124
  # Set expiration with 30 second buffer before actual expiration
123
125
  expires_in = max(0, token_response.expiresIn - 30)
124
126
  self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
125
-
127
+
126
128
  except httpx.HTTPError as e:
127
129
  raise ConnectionError(f"Failed to get client token: {str(e)}")
128
130
  except Exception as e:
129
131
  if isinstance(e, (AuthenticationError, ConnectionError)):
130
132
  raise
131
133
  raise AuthenticationError(f"Failed to get client token: {str(e)}")
132
-
134
+
133
135
  async def _ensure_client_token(self):
134
136
  """Ensure client token is set in headers."""
135
137
  token = await self._get_client_token()
136
138
  if self.client:
137
139
  self.client.headers["x-client-token"] = token
138
-
140
+
139
141
  async def close(self):
140
142
  """Close the HTTP client."""
141
143
  if self.client:
142
144
  await self.client.aclose()
143
145
  self.client = None
144
-
146
+
145
147
  async def __aenter__(self):
146
148
  """Async context manager entry."""
147
149
  return self
148
-
150
+
149
151
  async def __aexit__(self, exc_type, exc_val, exc_tb):
150
152
  """Async context manager exit."""
151
153
  await self.close()
152
-
154
+
153
155
  async def get(self, url: str, **kwargs) -> Any:
154
156
  """
155
157
  Make GET request.
156
-
158
+
157
159
  Args:
158
160
  url: Request URL
159
161
  **kwargs: Additional httpx request parameters
160
-
162
+
161
163
  Returns:
162
164
  Response data (JSON parsed)
163
-
165
+
164
166
  Raises:
165
167
  MisoClientError: If request fails
166
168
  """
@@ -169,35 +171,37 @@ class HttpClient:
169
171
  try:
170
172
  assert self.client is not None
171
173
  response = await self.client.get(url, **kwargs)
172
-
174
+
173
175
  # Handle 401 - clear token to force refresh
174
176
  if response.status_code == 401:
175
177
  self.client_token = None
176
178
  self.token_expires_at = None
177
-
179
+
178
180
  response.raise_for_status()
179
181
  return response.json()
180
182
  except httpx.HTTPStatusError as e:
181
183
  raise MisoClientError(
182
184
  f"HTTP {e.response.status_code}: {e.response.text}",
183
185
  status_code=e.response.status_code,
184
- error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
186
+ error_body=e.response.json()
187
+ if e.response.headers.get("content-type", "").startswith("application/json")
188
+ else {},
185
189
  )
186
190
  except httpx.RequestError as e:
187
191
  raise ConnectionError(f"Request failed: {str(e)}")
188
-
192
+
189
193
  async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
190
194
  """
191
195
  Make POST request.
192
-
196
+
193
197
  Args:
194
198
  url: Request URL
195
199
  data: Request data (will be JSON encoded)
196
200
  **kwargs: Additional httpx request parameters
197
-
201
+
198
202
  Returns:
199
203
  Response data (JSON parsed)
200
-
204
+
201
205
  Raises:
202
206
  MisoClientError: If request fails
203
207
  """
@@ -206,34 +210,36 @@ class HttpClient:
206
210
  try:
207
211
  assert self.client is not None
208
212
  response = await self.client.post(url, json=data, **kwargs)
209
-
213
+
210
214
  if response.status_code == 401:
211
215
  self.client_token = None
212
216
  self.token_expires_at = None
213
-
217
+
214
218
  response.raise_for_status()
215
219
  return response.json()
216
220
  except httpx.HTTPStatusError as e:
217
221
  raise MisoClientError(
218
222
  f"HTTP {e.response.status_code}: {e.response.text}",
219
223
  status_code=e.response.status_code,
220
- error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
224
+ error_body=e.response.json()
225
+ if e.response.headers.get("content-type", "").startswith("application/json")
226
+ else {},
221
227
  )
222
228
  except httpx.RequestError as e:
223
229
  raise ConnectionError(f"Request failed: {str(e)}")
224
-
230
+
225
231
  async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
226
232
  """
227
233
  Make PUT request.
228
-
234
+
229
235
  Args:
230
236
  url: Request URL
231
237
  data: Request data (will be JSON encoded)
232
238
  **kwargs: Additional httpx request parameters
233
-
239
+
234
240
  Returns:
235
241
  Response data (JSON parsed)
236
-
242
+
237
243
  Raises:
238
244
  MisoClientError: If request fails
239
245
  """
@@ -242,33 +248,35 @@ class HttpClient:
242
248
  try:
243
249
  assert self.client is not None
244
250
  response = await self.client.put(url, json=data, **kwargs)
245
-
251
+
246
252
  if response.status_code == 401:
247
253
  self.client_token = None
248
254
  self.token_expires_at = None
249
-
255
+
250
256
  response.raise_for_status()
251
257
  return response.json()
252
258
  except httpx.HTTPStatusError as e:
253
259
  raise MisoClientError(
254
260
  f"HTTP {e.response.status_code}: {e.response.text}",
255
261
  status_code=e.response.status_code,
256
- error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
262
+ error_body=e.response.json()
263
+ if e.response.headers.get("content-type", "").startswith("application/json")
264
+ else {},
257
265
  )
258
266
  except httpx.RequestError as e:
259
267
  raise ConnectionError(f"Request failed: {str(e)}")
260
-
268
+
261
269
  async def delete(self, url: str, **kwargs) -> Any:
262
270
  """
263
271
  Make DELETE request.
264
-
272
+
265
273
  Args:
266
274
  url: Request URL
267
275
  **kwargs: Additional httpx request parameters
268
-
276
+
269
277
  Returns:
270
278
  Response data (JSON parsed)
271
-
279
+
272
280
  Raises:
273
281
  MisoClientError: If request fails
274
282
  """
@@ -277,41 +285,43 @@ class HttpClient:
277
285
  try:
278
286
  assert self.client is not None
279
287
  response = await self.client.delete(url, **kwargs)
280
-
288
+
281
289
  if response.status_code == 401:
282
290
  self.client_token = None
283
291
  self.token_expires_at = None
284
-
292
+
285
293
  response.raise_for_status()
286
294
  return response.json()
287
295
  except httpx.HTTPStatusError as e:
288
296
  raise MisoClientError(
289
297
  f"HTTP {e.response.status_code}: {e.response.text}",
290
298
  status_code=e.response.status_code,
291
- error_body=e.response.json() if e.response.headers.get("content-type", "").startswith("application/json") else {}
299
+ error_body=e.response.json()
300
+ if e.response.headers.get("content-type", "").startswith("application/json")
301
+ else {},
292
302
  )
293
303
  except httpx.RequestError as e:
294
304
  raise ConnectionError(f"Request failed: {str(e)}")
295
-
305
+
296
306
  async def request(
297
307
  self,
298
308
  method: Literal["GET", "POST", "PUT", "DELETE"],
299
309
  url: str,
300
310
  data: Optional[Dict[str, Any]] = None,
301
- **kwargs
311
+ **kwargs,
302
312
  ) -> Any:
303
313
  """
304
314
  Generic request method.
305
-
315
+
306
316
  Args:
307
317
  method: HTTP method
308
318
  url: Request URL
309
319
  data: Request data (for POST/PUT)
310
320
  **kwargs: Additional httpx request parameters
311
-
321
+
312
322
  Returns:
313
323
  Response data (JSON parsed)
314
-
324
+
315
325
  Raises:
316
326
  MisoClientError: If request fails
317
327
  """
@@ -326,51 +336,51 @@ class HttpClient:
326
336
  return await self.delete(url, **kwargs)
327
337
  else:
328
338
  raise ValueError(f"Unsupported HTTP method: {method}")
329
-
339
+
330
340
  async def authenticated_request(
331
341
  self,
332
342
  method: Literal["GET", "POST", "PUT", "DELETE"],
333
343
  url: str,
334
344
  token: str,
335
345
  data: Optional[Dict[str, Any]] = None,
336
- **kwargs
346
+ **kwargs,
337
347
  ) -> Any:
338
348
  """
339
349
  Make authenticated request with Bearer token.
340
-
350
+
341
351
  IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
342
352
  User token is sent as Authorization: Bearer header (this method parameter)
343
353
  These are two separate tokens for different purposes.
344
-
354
+
345
355
  Args:
346
356
  method: HTTP method
347
357
  url: Request URL
348
358
  token: User authentication token (sent as Bearer token)
349
359
  data: Request data (for POST/PUT)
350
360
  **kwargs: Additional httpx request parameters
351
-
361
+
352
362
  Returns:
353
363
  Response data (JSON parsed)
354
-
364
+
355
365
  Raises:
356
366
  MisoClientError: If request fails
357
367
  """
358
368
  await self._ensure_client_token()
359
-
369
+
360
370
  # Add Bearer token for user authentication
361
371
  # x-client-token is automatically added by _ensure_client_token
362
372
  headers = kwargs.get("headers", {})
363
373
  headers["Authorization"] = f"Bearer {token}"
364
374
  kwargs["headers"] = headers
365
-
375
+
366
376
  return await self.request(method, url, data, **kwargs)
367
-
377
+
368
378
  async def get_environment_token(self) -> str:
369
379
  """
370
380
  Get environment token using client credentials.
371
-
381
+
372
382
  This is called automatically by HttpClient but can be called manually.
373
-
383
+
374
384
  Returns:
375
385
  Client token string
376
386
  """
@@ -5,21 +5,22 @@ This module provides utilities for extracting information from JWT tokens
5
5
  without verification, used for cache optimization and context extraction.
6
6
  """
7
7
 
8
+ from typing import Any, Dict, Optional, cast
9
+
8
10
  import jwt
9
- from typing import Optional, Dict, Any, cast
10
11
 
11
12
 
12
13
  def decode_token(token: str) -> Optional[Dict[str, Any]]:
13
14
  """
14
15
  Safely decode JWT token without verification.
15
-
16
+
16
17
  This is used for extracting user information (like userId) from tokens
17
18
  for cache optimization. The token is NOT verified - it should only be
18
19
  used for cache key generation, not for authentication decisions.
19
-
20
+
20
21
  Args:
21
22
  token: JWT token string
22
-
23
+
23
24
  Returns:
24
25
  Decoded token payload as dictionary, or None if decoding fails
25
26
  """
@@ -35,44 +36,40 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
35
36
  def extract_user_id(token: str) -> Optional[str]:
36
37
  """
37
38
  Extract user ID from JWT token.
38
-
39
+
39
40
  Tries common JWT claim fields: sub, userId, user_id, id
40
-
41
+
41
42
  Args:
42
43
  token: JWT token string
43
-
44
+
44
45
  Returns:
45
46
  User ID string if found, None otherwise
46
47
  """
47
48
  decoded = decode_token(token)
48
49
  if not decoded:
49
50
  return None
50
-
51
+
51
52
  # Try common JWT claim fields for user ID
52
53
  user_id = (
53
- decoded.get("sub") or
54
- decoded.get("userId") or
55
- decoded.get("user_id") or
56
- decoded.get("id")
54
+ decoded.get("sub") or decoded.get("userId") or decoded.get("user_id") or decoded.get("id")
57
55
  )
58
-
56
+
59
57
  return str(user_id) if user_id else None
60
58
 
61
59
 
62
60
  def extract_session_id(token: str) -> Optional[str]:
63
61
  """
64
62
  Extract session ID from JWT token.
65
-
63
+
66
64
  Args:
67
65
  token: JWT token string
68
-
66
+
69
67
  Returns:
70
68
  Session ID string if found, None otherwise
71
69
  """
72
70
  decoded = decode_token(token)
73
71
  if not decoded:
74
72
  return None
75
-
73
+
76
74
  value = decoded.get("sid") or decoded.get("sessionId")
77
75
  return value if isinstance(value, str) else None
78
-