simplyplural-cli 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,3 @@
1
+ """Simply Plural CLI - Command line interface for Simply Plural"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,616 @@
1
+ """
2
+ Simply Plural API Client
3
+
4
+ Handles all communication with the Simply Plural API, including:
5
+ - Authentication
6
+ - Rate limiting
7
+ - Error handling
8
+ - Endpoint abstraction
9
+ """
10
+
11
+ import requests
12
+ import time
13
+ import json
14
+ from typing import List, Dict, Any, Optional
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+
19
+ class APIError(Exception):
20
+ """Exception raised for API-related errors"""
21
+ pass
22
+
23
+
24
+ class SimplyPluralAPI:
25
+ """Simply Plural API client"""
26
+
27
+ BASE_URL = "https://api.apparyllis.com/v1"
28
+
29
+ def __init__(self, api_token: str, config_manager=None, debug: bool = False, cache_manager=None):
30
+ self.api_token = api_token
31
+ self.config = config_manager
32
+ self.debug = debug
33
+ self.cache = cache_manager
34
+
35
+ # Get timeout and retry settings from config
36
+ self.timeout = config_manager.api_timeout if config_manager else 10
37
+ self.max_retries = config_manager.max_retries if config_manager else 3
38
+
39
+ self.session = requests.Session()
40
+ self.session.headers.update({
41
+ 'Authorization': api_token,
42
+ 'User-Agent': 'SimplyPlural-CLI/1.0',
43
+ 'Content-Type': 'application/json'
44
+ })
45
+
46
+ def _filter_sensitive_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
47
+ """Filter out any potentially sensitive headers from debug output"""
48
+ # Headers that should never be logged (case-insensitive)
49
+ sensitive_headers = {'authorization', 'x-api-key', 'x-auth-token', 'bearer'}
50
+
51
+ filtered = {}
52
+ for key, value in headers.items():
53
+ if key.lower() in sensitive_headers:
54
+ filtered[key] = "[REDACTED]"
55
+ else:
56
+ filtered[key] = value
57
+ return filtered
58
+
59
+ def _sanitize_debug_data(self, data: Any) -> Any:
60
+ """Remove any potentially sensitive data from debug output"""
61
+ if isinstance(data, dict):
62
+ sanitized = {}
63
+ for key, value in data.items():
64
+ if any(sensitive in key.lower() for sensitive in ['token', 'auth', 'password', 'secret']):
65
+ sanitized[key] = "[REDACTED]"
66
+ else:
67
+ sanitized[key] = self._sanitize_debug_data(value)
68
+ return sanitized
69
+ elif isinstance(data, list):
70
+ return [self._sanitize_debug_data(item) for item in data]
71
+ else:
72
+ return data
73
+
74
+ def _sanitize_debug_text(self, text: str) -> str:
75
+ """Remove tokens from debug text output"""
76
+ # Simple pattern to catch base64-like tokens (44+ chars of base64)
77
+ import re
78
+ # Replace what looks like long base64 tokens with [REDACTED]
79
+ sanitized = re.sub(r'[A-Za-z0-9+/]{44,}={0,2}', '[REDACTED_TOKEN]', text)
80
+ return sanitized
81
+
82
+ def _generate_fallback_name(self, member_id: str, fronter: Dict[str, Any]) -> str:
83
+ """Generate a more useful fallback name when member details aren't available"""
84
+ # Extract potentially useful info from the fronter object
85
+ custom_status = fronter.get('content', {}).get('customStatus', '')
86
+
87
+ # Use middle section of ID for better uniqueness (where IDs actually differ)
88
+ if len(member_id) >= 16:
89
+ # Show chars 8-16 which tend to be more unique
90
+ unique_part = member_id[8:16]
91
+ short_id = f"ID-{unique_part}"
92
+ elif len(member_id) >= 8:
93
+ # Fallback to first 8 chars
94
+ short_id = f"ID-{member_id[:8]}"
95
+ else:
96
+ short_id = f"ID-{member_id}"
97
+
98
+ # If there's a custom status, include it
99
+ if custom_status and custom_status.strip():
100
+ return f"{short_id} ({custom_status.strip()})"
101
+ else:
102
+ return short_id
103
+
104
+ def _request(self, method: str, endpoint: str, **kwargs) -> Dict[Any, Any]:
105
+ """Make an API request with retries"""
106
+
107
+ url = f"{self.BASE_URL}{endpoint}"
108
+ # Use configured timeout, allow override via kwargs
109
+ timeout = kwargs.pop('timeout', self.timeout)
110
+
111
+ if self.debug:
112
+ print(f"DEBUG: {method} {url}")
113
+ if 'json' in kwargs:
114
+ print(f"DEBUG: Request body: {json.dumps(kwargs['json'], indent=2)}")
115
+ if 'params' in kwargs:
116
+ print(f"DEBUG: Query params: {kwargs['params']}")
117
+
118
+ last_exception = None
119
+
120
+ # Retry loop
121
+ for attempt in range(self.max_retries):
122
+ try:
123
+ response = self.session.request(method, url, timeout=timeout, **kwargs)
124
+
125
+ if self.debug:
126
+ print(f"DEBUG: Response status: {response.status_code}")
127
+ # Safely show response headers (never contains our auth token)
128
+ safe_headers = self._filter_sensitive_headers(dict(response.headers))
129
+ print(f"DEBUG: Response headers: {safe_headers}")
130
+ # Sanitize response text to prevent token leaks
131
+ sanitized_response = self._sanitize_debug_text(response.text)
132
+ print(f"DEBUG: Response text: {sanitized_response[:500]}{'...' if len(sanitized_response) > 500 else ''}")
133
+
134
+ # Handle rate limiting
135
+ if response.status_code == 429:
136
+ retry_after = int(response.headers.get('Retry-After', 60))
137
+ if self.debug:
138
+ print(f"DEBUG: Server returned HTTP 429 - actual rate limit from API")
139
+ raise APIError(f"Rate limited (server). Retry after {retry_after} seconds.")
140
+
141
+ # Handle other HTTP errors
142
+ if response.status_code == 401:
143
+ raise APIError("HTTP 401 - Bad Request. Check if your token has the correct permissions (in particular, write permissions are needed to update fronters). If you are sure your token is not the problem, open a bug report at https://github.com/SiteRelEnby/simplyplural-cli/issues")
144
+ elif response.status_code == 403:
145
+ if self.debug:
146
+ print(f"DEBUG: HTTP 403 - Access denied. Check your token is entered correctly and not revoked.")
147
+ raise APIError("HTTP 403 - Access denied. Check your token is entered correctly and not revoked.")
148
+ elif response.status_code == 404:
149
+ if self.debug:
150
+ print(f"DEBUG: HTTP 404 - Endpoint {endpoint} not found")
151
+ raise APIError("HTTP 404 - Not found. This is probably a bug - open a report at https://github.com/SiteRelEnby/simplyplural-cli/issues")
152
+ elif response.status_code >= 400:
153
+ try:
154
+ error_data = response.json()
155
+ error_msg = error_data.get('message', f'HTTP {response.status_code}')
156
+ except:
157
+ error_msg = f'HTTP {response.status_code}: {response.text[:100]}'
158
+ if self.debug:
159
+ sanitized_error = self._sanitize_debug_text(error_msg)
160
+ print(f"DEBUG: HTTP {response.status_code} error: {sanitized_error}")
161
+ raise APIError(error_msg)
162
+
163
+ # Parse JSON response
164
+ try:
165
+ # Handle empty successful responses (like PATCH updates)
166
+ if response.status_code == 200 and not response.text.strip():
167
+ return {}
168
+
169
+ result = response.json()
170
+ if self.debug:
171
+ print(f"DEBUG: Parsed JSON: {json.dumps(self._sanitize_debug_data(result), indent=2) if result else 'None'}")
172
+ return result
173
+ except json.JSONDecodeError:
174
+ if response.status_code == 204: # No Content
175
+ return {}
176
+ # For successful responses with empty content, return empty dict
177
+ if response.status_code == 200 and not response.text.strip():
178
+ return {}
179
+ raise APIError("Invalid JSON response from API")
180
+
181
+ except (requests.Timeout, requests.ConnectionError, requests.RequestException) as e:
182
+ last_exception = e
183
+ if attempt < self.max_retries - 1:
184
+ if self.debug:
185
+ print(f"DEBUG: Request failed (attempt {attempt + 1}/{self.max_retries}): {e}")
186
+ # Exponential backoff for retries
187
+ time.sleep(2 ** attempt)
188
+ continue
189
+ else:
190
+ # Final attempt failed
191
+ if isinstance(e, requests.Timeout):
192
+ raise APIError("Request timed out. Check your connection.")
193
+ elif isinstance(e, requests.ConnectionError):
194
+ raise APIError("Connection failed. Check your internet connection.")
195
+ else:
196
+ raise APIError(f"Request failed: {e}")
197
+
198
+ # This shouldn't be reached, but just in case
199
+ raise APIError(f"Request failed after {self.max_retries} attempts: {last_exception}")
200
+
201
+ def get_fronters(self) -> Dict[str, Any]:
202
+ """Get current fronters with resolved member/custom front names"""
203
+ fronters_response = self._request('GET', '/fronters')
204
+
205
+ # If it's a list of fronter objects, try to resolve names
206
+ if isinstance(fronters_response, list):
207
+ resolved_fronters = []
208
+ for fronter in fronters_response:
209
+ if 'content' in fronter and 'member' in fronter['content']:
210
+ entity_id = fronter['content']['member']
211
+ is_custom = fronter['content'].get('custom', False)
212
+
213
+ try:
214
+ if is_custom:
215
+ # It's a custom front
216
+ custom_front = self.get_custom_front(entity_id)
217
+ fronter_with_name = fronter.copy()
218
+ fronter_with_name['name'] = custom_front.get('content', {}).get('name', self._generate_fallback_name(entity_id, fronter))
219
+ fronter_with_name['type'] = 'custom_front'
220
+ else:
221
+ # It's a regular member
222
+ member = self.get_member(entity_id)
223
+ fronter_with_name = fronter.copy()
224
+ fronter_with_name['name'] = member.get('content', {}).get('name', self._generate_fallback_name(entity_id, fronter))
225
+ fronter_with_name['type'] = 'member'
226
+
227
+ resolved_fronters.append(fronter_with_name)
228
+ except APIError:
229
+ # If we can't get details, use ID as fallback
230
+ fronter_with_name = fronter.copy()
231
+ fronter_with_name['name'] = self._generate_fallback_name(entity_id, fronter)
232
+ fronter_with_name['type'] = 'custom_front' if is_custom else 'member'
233
+ resolved_fronters.append(fronter_with_name)
234
+ else:
235
+ resolved_fronters.append(fronter)
236
+ return resolved_fronters
237
+
238
+ return fronters_response
239
+
240
+ def get_system_id(self) -> str:
241
+ """Get the system ID from /me endpoint"""
242
+ # Check if we have it cached
243
+ if hasattr(self, '_system_id') and self._system_id:
244
+ return self._system_id
245
+
246
+ try:
247
+ response = self._request('GET', '/me')
248
+ # The system ID might be in different fields
249
+ system_id = None
250
+
251
+ if isinstance(response, dict):
252
+ # Try common field names for system ID
253
+ for field in ['id', 'uid', 'system_id', 'systemId', 'user_id', 'userId']:
254
+ if field in response and response[field]:
255
+ system_id = response[field]
256
+ break
257
+
258
+ # Also check if it's nested
259
+ if not system_id and 'content' in response:
260
+ content = response['content']
261
+ for field in ['id', 'uid', 'system_id', 'systemId']:
262
+ if field in content and content[field]:
263
+ system_id = content[field]
264
+ break
265
+
266
+ if not system_id:
267
+ if self.debug:
268
+ print(f"DEBUG: Could not find system ID in /me response: {response}")
269
+ raise APIError("Could not extract system ID from /me endpoint")
270
+
271
+ # Cache it
272
+ self._system_id = system_id
273
+ if self.debug:
274
+ print(f"DEBUG: Found system ID: {system_id}")
275
+
276
+ return system_id
277
+
278
+ except APIError as e:
279
+ if self.debug:
280
+ print(f"DEBUG: Failed to get system ID from /me: {e}")
281
+ raise APIError(f"Could not get system ID: {e}")
282
+
283
+ def get_members(self) -> List[Dict[str, Any]]:
284
+ """Get all members using the correct /members/{system_id} format"""
285
+ try:
286
+ # Get system ID first
287
+ system_id = self.get_system_id()
288
+
289
+ # Try the correct endpoint format
290
+ endpoint = f'/members/{system_id}'
291
+
292
+ if self.debug:
293
+ print(f"DEBUG: Trying members endpoint {endpoint}")
294
+
295
+ response = self._request('GET', endpoint)
296
+
297
+ # Handle different response formats
298
+ if isinstance(response, list):
299
+ return response
300
+ elif isinstance(response, dict):
301
+ # Try common keys for member lists
302
+ for key in ['members', 'profiles', 'system', 'data']:
303
+ if key in response and isinstance(response[key], list):
304
+ return response[key]
305
+
306
+ # If it's a single member object, wrap in list
307
+ if 'id' in response:
308
+ return [response]
309
+
310
+ if self.debug:
311
+ print(f"DEBUG: Unexpected response format from {endpoint}: {type(response)}")
312
+ print(f"DEBUG: Response: {response}")
313
+
314
+ # If we got here, try fallback endpoints
315
+ if self.debug:
316
+ print(f"DEBUG: Trying fallback endpoints...")
317
+
318
+ fallback_endpoints = [
319
+ '/members', # Maybe it works without system ID
320
+ '/profiles', # Maybe it's called profiles
321
+ '/system', # Maybe system info includes members
322
+ f'/system/{system_id}/members', # Alternative format
323
+ f'/user/{system_id}/members', # User-scoped format
324
+ ]
325
+
326
+ for fallback_endpoint in fallback_endpoints:
327
+ try:
328
+ if self.debug:
329
+ print(f"DEBUG: Trying fallback endpoint {fallback_endpoint}")
330
+ response = self._request('GET', fallback_endpoint)
331
+
332
+ if isinstance(response, list):
333
+ return response
334
+ elif isinstance(response, dict):
335
+ for key in ['members', 'profiles', 'system', 'data']:
336
+ if key in response and isinstance(response[key], list):
337
+ return response[key]
338
+
339
+ except APIError as e:
340
+ if self.debug:
341
+ print(f"DEBUG: Fallback endpoint {fallback_endpoint} failed: {e}")
342
+ continue
343
+
344
+ raise APIError(f"No valid members endpoint found. Tried {endpoint} and fallbacks.")
345
+
346
+ except APIError as e:
347
+ if "Could not get system ID" in str(e):
348
+ # If we can't get system ID, the user might need to check their token permissions
349
+ raise APIError(f"Cannot get member list: {e}. Check if your API token has the required permissions.")
350
+ else:
351
+ raise e
352
+
353
+ def get_member(self, member_id: str) -> Dict[str, Any]:
354
+ """Get a specific member with caching using the correct /member/{system_id}/{member_id} format"""
355
+ # Check cache first
356
+ if self.cache:
357
+ cached_member = self.cache.get_member(member_id)
358
+ if cached_member:
359
+ if self.debug:
360
+ print(f"DEBUG: Using cached member {member_id}: {cached_member.get('content', {}).get('name', 'Unknown')}")
361
+ return cached_member
362
+
363
+ if self.debug:
364
+ print(f"DEBUG: Cache miss for member {member_id}, fetching from API")
365
+
366
+ try:
367
+ # Get system ID first
368
+ system_id = self.get_system_id()
369
+
370
+ # Use the correct endpoint format
371
+ endpoint = f'/member/{system_id}/{member_id}'
372
+
373
+ if self.debug:
374
+ print(f"DEBUG: Trying to get member {member_id} from {endpoint}")
375
+
376
+ member_data = self._request('GET', endpoint)
377
+
378
+ # Cache the result
379
+ if self.cache:
380
+ self.cache.set_member(member_id, member_data)
381
+ if self.debug:
382
+ print(f"DEBUG: Cached member {member_id}: {member_data.get('content', {}).get('name', 'Unknown')}")
383
+
384
+ return member_data
385
+
386
+ except APIError as e:
387
+ if self.debug:
388
+ print(f"DEBUG: Failed to get member {member_id}: {e}")
389
+ raise APIError(f"Could not fetch member {member_id}: {e}")
390
+
391
+ def get_custom_fronts(self) -> List[Dict[str, Any]]:
392
+ """Get all custom fronts for this system"""
393
+ # Try cache first
394
+ if self.cache:
395
+ cached_custom_fronts = self.cache.get_custom_fronts()
396
+ if cached_custom_fronts is not None:
397
+ if self.debug:
398
+ print(f"DEBUG: Using cached custom fronts: {len(cached_custom_fronts)} custom fronts")
399
+ return cached_custom_fronts
400
+
401
+ try:
402
+ system_id = self.get_system_id()
403
+ if self.debug:
404
+ print(f"DEBUG: Fetching custom fronts for system {system_id}")
405
+
406
+ response = self._request('GET', f'/customFronts/{system_id}')
407
+
408
+ # Return the list of custom fronts
409
+ custom_fronts = response if isinstance(response, list) else []
410
+
411
+ # Cache the results
412
+ if self.cache:
413
+ self.cache.set_custom_fronts(custom_fronts)
414
+
415
+ return custom_fronts
416
+
417
+ except APIError as e:
418
+ if self.debug:
419
+ print(f"DEBUG: Failed to get custom fronts: {e}")
420
+ raise APIError(f"Could not fetch custom fronts: {e}")
421
+
422
+ def get_custom_front(self, custom_front_id: str) -> Dict[str, Any]:
423
+ """Get a specific custom front by ID"""
424
+ # Try cache first
425
+ if self.cache:
426
+ cached_custom_front = self.cache.get_custom_front(custom_front_id)
427
+ if cached_custom_front is not None:
428
+ if self.debug:
429
+ print(f"DEBUG: Using cached custom front {custom_front_id}")
430
+ return cached_custom_front
431
+
432
+ try:
433
+ system_id = self.get_system_id()
434
+ if self.debug:
435
+ print(f"DEBUG: Fetching custom front {custom_front_id} for system {system_id}")
436
+
437
+ response = self._request('GET', f'/customFront/{system_id}/{custom_front_id}')
438
+
439
+ if 'content' in response:
440
+ # Cache the result
441
+ if self.cache:
442
+ self.cache.set_custom_front(custom_front_id, response)
443
+ return response
444
+ else:
445
+ raise APIError(f"Invalid custom front response format")
446
+
447
+ except APIError as e:
448
+ if self.debug:
449
+ print(f"DEBUG: Failed to get custom front {custom_front_id}: {e}")
450
+ raise APIError(f"Could not fetch custom front {custom_front_id}: {e}")
451
+
452
+ def register_switch(self, names: List[str], note: Optional[str] = None) -> Dict[str, Any]:
453
+ """Register a switch to one or more members or custom fronts using the frontHistory API"""
454
+
455
+ # Get both members and custom fronts
456
+ members = self.get_members()
457
+ custom_fronts = self.get_custom_fronts()
458
+
459
+ # Create maps for name lookup
460
+ member_map = {m['content']['name'].lower(): {'id': m['id'], 'type': 'member'} for m in members}
461
+ custom_front_map = {cf['content']['name'].lower(): {'id': cf['id'], 'type': 'custom_front'} for cf in custom_fronts}
462
+
463
+ # Combine both maps for unified lookup
464
+ entity_map = {**member_map, **custom_front_map}
465
+
466
+ entities = []
467
+ for name in names:
468
+ entity = entity_map.get(name.lower())
469
+ if not entity:
470
+ # Try partial matching in both members and custom fronts
471
+ member_matches = [m for m in members if name.lower() in m['content']['name'].lower()]
472
+ custom_front_matches = [cf for cf in custom_fronts if name.lower() in cf['content']['name'].lower()]
473
+
474
+ all_matches = [
475
+ {'entity': m, 'type': 'member'} for m in member_matches
476
+ ] + [
477
+ {'entity': cf, 'type': 'custom_front'} for cf in custom_front_matches
478
+ ]
479
+
480
+ if len(all_matches) == 1:
481
+ match = all_matches[0]
482
+ entity = {'id': match['entity']['id'], 'type': match['type']}
483
+ elif len(all_matches) > 1:
484
+ names_list = [f"{match['entity']['content']['name']} ({match['type']})" for match in all_matches]
485
+ raise APIError(f"Ambiguous name '{name}'. Matches: {', '.join(names_list)}")
486
+ else:
487
+ available_members = [m['content']['name'] for m in members]
488
+ available_custom_fronts = [cf['content']['name'] for cf in custom_fronts]
489
+ all_available = [f"{name} (member)" for name in available_members] + [f"{name} (custom_front)" for name in available_custom_fronts]
490
+ raise APIError(f"Name '{name}' not found. Available: {', '.join(all_available)}")
491
+ entities.append(entity)
492
+
493
+ # Step 1: End all current live fronting sessions
494
+ current_fronters = self._request('GET', '/fronters')
495
+ current_time_ms = int(time.time() * 1000)
496
+
497
+ if self.debug:
498
+ print(f"DEBUG: Found {len(current_fronters)} current fronters to end")
499
+
500
+ for fronter in current_fronters:
501
+ if fronter.get('content', {}).get('live', False):
502
+ front_id = fronter['id']
503
+ if self.debug:
504
+ print(f"DEBUG: Ending front session {front_id}")
505
+
506
+ end_data = {
507
+ 'live': False,
508
+ 'endTime': current_time_ms
509
+ }
510
+
511
+ try:
512
+ self._request('PATCH', f'/frontHistory/{front_id}', json=end_data)
513
+ except APIError as e:
514
+ if self.debug:
515
+ print(f"DEBUG: Warning - failed to end front session {front_id}: {e}")
516
+ # Continue anyway - maybe it was already ended
517
+
518
+ # Step 2: Create new front sessions for the requested entities (members or custom fronts)
519
+ import random
520
+ results = []
521
+
522
+ for entity in entities:
523
+ # Generate a new ObjectId-style string (24 hex characters)
524
+ new_front_id = ''.join(random.choices('0123456789abcdef', k=24))
525
+
526
+ start_data = {
527
+ 'member': entity['id'],
528
+ 'startTime': current_time_ms + 1, # Slightly after end time
529
+ 'live': True,
530
+ 'custom': entity['type'] == 'custom_front'
531
+ }
532
+
533
+ if note:
534
+ start_data['customStatus'] = note
535
+
536
+ if self.debug:
537
+ entity_type = 'custom front' if entity['type'] == 'custom_front' else 'member'
538
+ print(f"DEBUG: Creating front session {new_front_id} for {entity_type} {entity['id']}")
539
+
540
+ result = self._request('POST', f'/frontHistory/{new_front_id}', json=start_data)
541
+ results.append(result)
542
+
543
+ return results[0] if len(results) == 1 else results
544
+
545
+ def get_switches(self, period: str = "recent", count: int = 10) -> List[Dict[str, Any]]:
546
+ """Get switch history using the correct frontHistory endpoint with required time parameters"""
547
+ try:
548
+ # Get system ID first
549
+ system_id = self.get_system_id()
550
+
551
+ # Calculate time range based on period
552
+ current_time_ms = int(time.time() * 1000)
553
+
554
+ if period == "today":
555
+ # Start of today
556
+ today = time.gmtime(time.time())
557
+ start_of_day = time.mktime((today.tm_year, today.tm_mon, today.tm_mday, 0, 0, 0, 0, 0, 0))
558
+ start_time_ms = int(start_of_day * 1000)
559
+ elif period == "week":
560
+ # Start of this week (7 days ago)
561
+ start_time_ms = current_time_ms - (7 * 24 * 60 * 60 * 1000)
562
+ else: # "recent" or any other value
563
+ # Default to last 30 days
564
+ start_time_ms = current_time_ms - (30 * 24 * 60 * 60 * 1000)
565
+
566
+ # Use the correct frontHistory endpoint with required parameters
567
+ endpoint = f'/frontHistory/{system_id}'
568
+ params = {
569
+ 'startTime': start_time_ms,
570
+ 'endTime': current_time_ms
571
+ }
572
+
573
+ if self.debug:
574
+ print(f"DEBUG: Using frontHistory endpoint: {endpoint}")
575
+ print(f"DEBUG: Time range: {start_time_ms} to {current_time_ms} ({period})")
576
+
577
+ response = self._request('GET', endpoint, params=params)
578
+
579
+ # Handle the response - it should be a list of front history entries
580
+ if isinstance(response, list):
581
+ # Sort by timestamp (most recent first) and limit count
582
+ sorted_entries = sorted(response,
583
+ key=lambda x: x.get('content', {}).get('startTime', 0),
584
+ reverse=True)
585
+ return sorted_entries[:count]
586
+ else:
587
+ if self.debug:
588
+ print(f"DEBUG: Unexpected frontHistory response format: {type(response)}")
589
+ return []
590
+
591
+ except APIError as e:
592
+ if self.debug:
593
+ print(f"DEBUG: Failed to get switch history: {e}")
594
+ raise APIError(f"Could not fetch switch history: {e}")
595
+
596
+ def export_data(self) -> Dict[str, Any]:
597
+ """Export all user data"""
598
+ data = {}
599
+
600
+ try:
601
+ data['members'] = self.get_members()
602
+ except APIError:
603
+ data['members'] = []
604
+
605
+ try:
606
+ data['fronters'] = self.get_fronters()
607
+ except APIError:
608
+ data['fronters'] = {}
609
+
610
+ try:
611
+ data['switches'] = self.get_switches(count=1000)
612
+ except APIError:
613
+ data['switches'] = []
614
+
615
+ data['exported_at'] = time.strftime('%Y-%m-%dT%H:%M:%S.000Z', time.gmtime())
616
+ return data