hpde-analytics-cli 1.0.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,4 @@
1
+ """HPDE Analytics CLI: Event Registration API integration for HPDE and Time Trials programs."""
2
+
3
+ __version__ = "1.0.0"
4
+ __app_name__ = "hpde-analytics-cli"
@@ -0,0 +1 @@
1
+ """API client module."""
@@ -0,0 +1,325 @@
1
+ """
2
+ MotorsportsReg API client module.
3
+
4
+ Provides a high-level interface for accessing MotorsportsReg API endpoints.
5
+ """
6
+
7
+ import time
8
+ from typing import Any, Dict, Optional
9
+
10
+ from requests_oauthlib import OAuth1Session
11
+
12
+ from hpde_analytics_cli.auth.oauth import MSROAuth
13
+
14
+
15
+ class APIError(Exception):
16
+ """Exception raised for API errors."""
17
+
18
+ def __init__(
19
+ self, message: str, status_code: Optional[int] = None, response_body: Optional[str] = None
20
+ ):
21
+ super().__init__(message)
22
+ self.status_code = status_code
23
+ self.response_body = response_body
24
+
25
+
26
+ class MSRClient:
27
+ """Client for interacting with MotorsportsReg API endpoints."""
28
+
29
+ def __init__(self, oauth: MSROAuth, organization_id: Optional[str] = None):
30
+ """
31
+ Initialize the API client.
32
+
33
+ Args:
34
+ oauth: Authenticated MSROAuth instance
35
+ organization_id: Organization ID for admin API access (optional)
36
+ """
37
+ self.oauth = oauth
38
+ self.base_url = oauth.base_url
39
+ self.organization_id = organization_id
40
+
41
+ # Retry configuration
42
+ self.max_retries = 3
43
+ self.retry_delay = 1.0 # seconds
44
+
45
+ def _get_session(self) -> OAuth1Session:
46
+ """Get an authenticated OAuth session."""
47
+ return self.oauth.get_oauth_session()
48
+
49
+ def _request(
50
+ self,
51
+ method: str,
52
+ endpoint: str,
53
+ params: Optional[Dict[str, Any]] = None,
54
+ include_org_header: bool = True,
55
+ retries: Optional[int] = None,
56
+ ) -> Dict[str, Any]:
57
+ """
58
+ Make an authenticated API request.
59
+
60
+ Args:
61
+ method: HTTP method (GET, POST, etc.)
62
+ endpoint: API endpoint path (e.g., /rest/me.json)
63
+ params: Query parameters
64
+ include_org_header: Include X-Organization-Id header
65
+ retries: Number of retries (defaults to self.max_retries)
66
+
67
+ Returns:
68
+ Parsed JSON response (unwrapped from MSR response envelope)
69
+
70
+ Raises:
71
+ APIError: If the request fails
72
+ """
73
+ if retries is None:
74
+ retries = self.max_retries
75
+
76
+ session = self._get_session()
77
+
78
+ # Ensure endpoint has .json suffix for JSON response
79
+ if not endpoint.endswith(".json"):
80
+ endpoint = endpoint + ".json"
81
+
82
+ url = f"{self.base_url}{endpoint}"
83
+
84
+ headers = {}
85
+ if include_org_header and self.organization_id:
86
+ headers["X-Organization-Id"] = self.organization_id
87
+
88
+ last_error: APIError = APIError("Request failed with unknown error")
89
+
90
+ for attempt in range(retries + 1):
91
+ try:
92
+ if method.upper() == "GET":
93
+ response = session.get(url, params=params, headers=headers)
94
+ elif method.upper() == "POST":
95
+ response = session.post(url, data=params, headers=headers)
96
+ else:
97
+ raise APIError(f"Unsupported HTTP method: {method}")
98
+
99
+ # Handle response
100
+ if response.status_code == 200:
101
+ data = response.json()
102
+ # MSR wraps responses in {"response": {...}}
103
+ if isinstance(data, dict) and "response" in data:
104
+ data = data["response"]
105
+ return data
106
+ elif response.status_code == 401:
107
+ raise APIError(
108
+ "Authentication failed - tokens may be invalid or expired",
109
+ status_code=401,
110
+ response_body=response.text,
111
+ )
112
+ elif response.status_code == 403:
113
+ raise APIError(
114
+ "Access forbidden - insufficient permissions",
115
+ status_code=403,
116
+ response_body=response.text,
117
+ )
118
+ elif response.status_code == 404:
119
+ raise APIError(
120
+ f"Resource not found: {endpoint}",
121
+ status_code=404,
122
+ response_body=response.text,
123
+ )
124
+ elif response.status_code >= 500:
125
+ # Server error - retry
126
+ last_error = APIError(
127
+ f"Server error: {response.status_code}",
128
+ status_code=response.status_code,
129
+ response_body=response.text,
130
+ )
131
+ if attempt < retries:
132
+ time.sleep(self.retry_delay * (attempt + 1))
133
+ continue
134
+ raise last_error
135
+ else:
136
+ raise APIError(
137
+ f"Request failed with status {response.status_code}",
138
+ status_code=response.status_code,
139
+ response_body=response.text,
140
+ )
141
+
142
+ except APIError:
143
+ raise
144
+ except Exception as e:
145
+ last_error = APIError(f"Request failed: {str(e)}")
146
+ if attempt < retries:
147
+ time.sleep(self.retry_delay * (attempt + 1))
148
+ continue
149
+ raise last_error
150
+
151
+ raise last_error
152
+
153
+ def get_me(self) -> Dict[str, Any]:
154
+ """
155
+ Get the authenticated user's profile.
156
+
157
+ Returns:
158
+ User profile data including organizations
159
+ """
160
+ return self._request("GET", "/rest/me", include_org_header=False)
161
+
162
+ def get_organization_calendar(self, organization_id: Optional[str] = None) -> Dict[str, Any]:
163
+ """
164
+ Get the organization's event calendar.
165
+
166
+ Args:
167
+ organization_id: Organization ID (uses default if not provided)
168
+
169
+ Returns:
170
+ Calendar data with list of events
171
+
172
+ Endpoint: GET /rest/calendars/organization/{organization_id}
173
+ """
174
+ org_id = organization_id or self.organization_id
175
+ if not org_id:
176
+ raise ValueError("Organization ID is required")
177
+
178
+ return self._request("GET", f"/rest/calendars/organization/{org_id}")
179
+
180
+ def get_event_entrylist(self, event_id: str) -> Dict[str, Any]:
181
+ """
182
+ Get the entry list for an event.
183
+
184
+ Args:
185
+ event_id: Event ID
186
+
187
+ Returns:
188
+ Entry list data
189
+
190
+ Endpoint: GET /rest/events/{event_id}/entrylist
191
+ """
192
+ if not event_id:
193
+ raise ValueError("Event ID is required")
194
+
195
+ return self._request("GET", f"/rest/events/{event_id}/entrylist")
196
+
197
+ def get_event_attendees(self, event_id: str) -> Dict[str, Any]:
198
+ """
199
+ Get complete attendee list for an event.
200
+
201
+ Args:
202
+ event_id: Event ID
203
+
204
+ Returns:
205
+ Attendee data
206
+
207
+ Endpoint: GET /rest/events/{event_id}/attendees
208
+ """
209
+ if not event_id:
210
+ raise ValueError("Event ID is required")
211
+
212
+ return self._request("GET", f"/rest/events/{event_id}/attendees")
213
+
214
+ def get_event_assignments(self, event_id: str) -> Dict[str, Any]:
215
+ """
216
+ Get event assignments/entries including vehicle information.
217
+
218
+ Args:
219
+ event_id: Event ID
220
+
221
+ Returns:
222
+ Assignment data with vehicle details
223
+
224
+ Endpoint: GET /rest/events/{event_id}/assignments
225
+ """
226
+ if not event_id:
227
+ raise ValueError("Event ID is required")
228
+
229
+ return self._request("GET", f"/rest/events/{event_id}/assignments")
230
+
231
+ def get_timing_feed(self, event_id: str) -> Dict[str, Any]:
232
+ """
233
+ Get timing and scoring feed for an event.
234
+
235
+ Args:
236
+ event_id: Event ID
237
+
238
+ Returns:
239
+ Timing/scoring data
240
+
241
+ Endpoint: GET /rest/events/{event_id}/feeds/timing
242
+ """
243
+ if not event_id:
244
+ raise ValueError("Event ID is required")
245
+
246
+ return self._request("GET", f"/rest/events/{event_id}/feeds/timing")
247
+
248
+ def get_all_endpoint_data(self, event_id: Optional[str] = None) -> Dict[str, Any]:
249
+ """
250
+ Fetch data from all available endpoints for field discovery.
251
+
252
+ Args:
253
+ event_id: Optional event ID for event-specific endpoints
254
+
255
+ Returns:
256
+ Dict with data from each endpoint, keyed by endpoint name
257
+ """
258
+ results = {}
259
+
260
+ # Always fetch user profile
261
+ print(" Fetching /rest/me...")
262
+ try:
263
+ results["me"] = self.get_me()
264
+ print(" [OK] User profile retrieved")
265
+ except APIError as e:
266
+ print(f" [ERROR] {e}")
267
+ results["me"] = {"error": str(e)}
268
+
269
+ # Fetch organization calendar
270
+ if self.organization_id:
271
+ print(f" Fetching organization calendar (org: {self.organization_id})...")
272
+ try:
273
+ results["calendar"] = self.get_organization_calendar()
274
+ print(" [OK] Calendar retrieved")
275
+
276
+ # Get first event ID if not provided
277
+ if not event_id:
278
+ events = results["calendar"].get("events", [])
279
+ if events:
280
+ event_id = events[0].get("id")
281
+ print(f" Using first event from calendar: {event_id}")
282
+ except APIError as e:
283
+ print(f" [ERROR] {e}")
284
+ results["calendar"] = {"error": str(e)}
285
+
286
+ # Event-specific endpoints (require event_id)
287
+ if event_id:
288
+ event_endpoints = [
289
+ ("entrylist", self.get_event_entrylist, "Entry list"),
290
+ ("attendees", self.get_event_attendees, "Attendees"),
291
+ ("assignments", self.get_event_assignments, "Assignments"),
292
+ ("timing", self.get_timing_feed, "Timing feed"),
293
+ ]
294
+
295
+ for key, method, description in event_endpoints:
296
+ print(f" Fetching {description} (event: {event_id})...")
297
+ try:
298
+ results[key] = method(event_id)
299
+ print(f" [OK] {description} retrieved")
300
+ except APIError as e:
301
+ print(f" [ERROR] {e}")
302
+ results[key] = {"error": str(e)}
303
+ else:
304
+ print(" [SKIP] No event ID available - skipping event-specific endpoints")
305
+ print(" Use --event-id to specify an event, or ensure calendar has events")
306
+
307
+ return results
308
+
309
+
310
+ def create_client_from_oauth(oauth: MSROAuth, organization_id: Optional[str] = None) -> MSRClient:
311
+ """
312
+ Create an API client from an authenticated OAuth handler.
313
+
314
+ Args:
315
+ oauth: Authenticated MSROAuth instance
316
+ organization_id: Optional organization ID override
317
+
318
+ Returns:
319
+ Configured MSRClient instance
320
+ """
321
+ # Use provided org ID, or fall back to first organization from profile
322
+ if not organization_id and oauth.organizations:
323
+ organization_id = oauth.organizations[0].get("id")
324
+
325
+ return MSRClient(oauth=oauth, organization_id=organization_id)
@@ -0,0 +1 @@
1
+ """OAuth authentication module."""
@@ -0,0 +1,256 @@
1
+ """
2
+ Credential Manager Module
3
+
4
+ Securely stores and retrieves OAuth credentials using the system keyring.
5
+ Falls back to environment variables/.env file if keyring is unavailable.
6
+ """
7
+
8
+ import getpass
9
+ import os
10
+ from typing import Optional, Tuple
11
+
12
+ # Application identifier for keyring storage
13
+ APP_NAME = "hpde-analytics-cli"
14
+
15
+ # Credential keys
16
+ KEY_CONSUMER_KEY = "msr_consumer_key"
17
+ KEY_CONSUMER_SECRET = "msr_consumer_secret"
18
+
19
+ # Try to import keyring, but don't fail if unavailable
20
+ try:
21
+ import keyring
22
+
23
+ KEYRING_AVAILABLE = True
24
+ except ImportError:
25
+ KEYRING_AVAILABLE = False
26
+
27
+
28
+ class CredentialManager:
29
+ """
30
+ Manages OAuth credentials with secure storage.
31
+
32
+ Priority order for credential retrieval:
33
+ 1. System keyring (if available and credentials stored)
34
+ 2. Environment variables
35
+ 3. Interactive prompt (for --configure)
36
+ """
37
+
38
+ def __init__(self, app_name: str = APP_NAME):
39
+ """
40
+ Initialize the credential manager.
41
+
42
+ Args:
43
+ app_name: Application identifier for keyring storage
44
+ """
45
+ self.app_name = app_name
46
+
47
+ def keyring_available(self) -> bool:
48
+ """Check if keyring is available and functional."""
49
+ if not KEYRING_AVAILABLE:
50
+ return False
51
+
52
+ # Test if keyring backend is actually working
53
+ try:
54
+ # Try to get a non-existent key to test functionality
55
+ keyring.get_password(self.app_name, "__test__")
56
+ return True
57
+ except Exception:
58
+ return False
59
+
60
+ def get_credentials_from_keyring(self) -> Tuple[Optional[str], Optional[str]]:
61
+ """
62
+ Retrieve credentials from system keyring.
63
+
64
+ Returns:
65
+ Tuple of (consumer_key, consumer_secret) or (None, None) if not found
66
+ """
67
+ if not self.keyring_available():
68
+ return None, None
69
+
70
+ try:
71
+ consumer_key = keyring.get_password(self.app_name, KEY_CONSUMER_KEY)
72
+ consumer_secret = keyring.get_password(self.app_name, KEY_CONSUMER_SECRET)
73
+ return consumer_key, consumer_secret
74
+ except Exception:
75
+ return None, None
76
+
77
+ def get_credentials_from_env(self) -> Tuple[Optional[str], Optional[str]]:
78
+ """
79
+ Retrieve credentials from environment variables.
80
+
81
+ Returns:
82
+ Tuple of (consumer_key, consumer_secret) or (None, None) if not found
83
+ """
84
+ consumer_key = os.environ.get("MSR_CONSUMER_KEY")
85
+ consumer_secret = os.environ.get("MSR_CONSUMER_SECRET")
86
+ return consumer_key, consumer_secret
87
+
88
+ def get_credentials(self) -> Tuple[str, str]:
89
+ """
90
+ Get credentials from the best available source.
91
+
92
+ Priority:
93
+ 1. System keyring
94
+ 2. Environment variables
95
+
96
+ Returns:
97
+ Tuple of (consumer_key, consumer_secret)
98
+
99
+ Raises:
100
+ ValueError: If credentials cannot be found from any source
101
+ """
102
+ # Try keyring first
103
+ consumer_key, consumer_secret = self.get_credentials_from_keyring()
104
+ if consumer_key and consumer_secret:
105
+ return consumer_key, consumer_secret
106
+
107
+ # Fall back to environment variables
108
+ consumer_key, consumer_secret = self.get_credentials_from_env()
109
+ if consumer_key and consumer_secret:
110
+ return consumer_key, consumer_secret
111
+
112
+ # No credentials found
113
+ raise ValueError(
114
+ "No credentials found. Run with --configure to set up credentials, "
115
+ "or set MSR_CONSUMER_KEY and MSR_CONSUMER_SECRET environment variables."
116
+ )
117
+
118
+ def store_credentials(self, consumer_key: str, consumer_secret: str) -> bool:
119
+ """
120
+ Store credentials in system keyring.
121
+
122
+ Args:
123
+ consumer_key: OAuth consumer key
124
+ consumer_secret: OAuth consumer secret
125
+
126
+ Returns:
127
+ True if stored successfully, False otherwise
128
+ """
129
+ if not self.keyring_available():
130
+ print("Warning: System keyring not available. Cannot store credentials securely.")
131
+ return False
132
+
133
+ try:
134
+ keyring.set_password(self.app_name, KEY_CONSUMER_KEY, consumer_key)
135
+ keyring.set_password(self.app_name, KEY_CONSUMER_SECRET, consumer_secret)
136
+ return True
137
+ except Exception as e:
138
+ print(f"Warning: Failed to store credentials in keyring: {e}")
139
+ return False
140
+
141
+ def delete_credentials(self) -> bool:
142
+ """
143
+ Delete credentials from system keyring.
144
+
145
+ Returns:
146
+ True if deleted successfully, False otherwise
147
+ """
148
+ if not self.keyring_available():
149
+ return False
150
+
151
+ try:
152
+ keyring.delete_password(self.app_name, KEY_CONSUMER_KEY)
153
+ keyring.delete_password(self.app_name, KEY_CONSUMER_SECRET)
154
+ return True
155
+ except Exception:
156
+ return False
157
+
158
+ def has_stored_credentials(self) -> bool:
159
+ """
160
+ Check if credentials are stored in keyring.
161
+
162
+ Returns:
163
+ True if credentials exist in keyring
164
+ """
165
+ consumer_key, consumer_secret = self.get_credentials_from_keyring()
166
+ return bool(consumer_key and consumer_secret)
167
+
168
+ def configure_interactive(self) -> bool:
169
+ """
170
+ Interactively configure credentials via command line prompts.
171
+
172
+ Returns:
173
+ True if configuration successful
174
+ """
175
+ print("\n" + "=" * 60)
176
+ print("HPDE Analytics - Credential Configuration")
177
+ print("=" * 60)
178
+
179
+ if not self.keyring_available():
180
+ print("\nWarning: System keyring is not available.")
181
+ print("Credentials will need to be set via environment variables.")
182
+ print("\nTo use environment variables, add to your .env file:")
183
+ print(" MSR_CONSUMER_KEY=your_key_here")
184
+ print(" MSR_CONSUMER_SECRET=your_secret_here")
185
+ return False
186
+
187
+ print("\nThis will store your MotorsportsReg OAuth credentials securely")
188
+ print("in your system's credential manager (Keychain/Credential Locker).")
189
+
190
+ # Check for existing credentials
191
+ if self.has_stored_credentials():
192
+ print("\nExisting credentials found in keyring.")
193
+ response = input("Do you want to replace them? (y/N): ").strip().lower()
194
+ if response != "y":
195
+ print("Configuration cancelled.")
196
+ return False
197
+
198
+ print("\nEnter your MotorsportsReg OAuth credentials:")
199
+ print("(These are provided by MotorsportsReg for API access)")
200
+ print()
201
+
202
+ # Get consumer key
203
+ consumer_key = input("Consumer Key: ").strip()
204
+ if not consumer_key:
205
+ print("Error: Consumer key cannot be empty.")
206
+ return False
207
+
208
+ # Get consumer secret (hidden input)
209
+ consumer_secret = getpass.getpass("Consumer Secret: ").strip()
210
+ if not consumer_secret:
211
+ print("Error: Consumer secret cannot be empty.")
212
+ return False
213
+
214
+ # Store credentials
215
+ if self.store_credentials(consumer_key, consumer_secret):
216
+ print("\n[OK] Credentials stored securely in system keyring.")
217
+ print(" You can now run the application without environment variables.")
218
+ return True
219
+ else:
220
+ print("\n[ERROR] Failed to store credentials.")
221
+ return False
222
+
223
+ def show_status(self) -> None:
224
+ """Display current credential configuration status."""
225
+ print("\n" + "=" * 60)
226
+ print("Credential Status")
227
+ print("=" * 60)
228
+
229
+ # Check keyring availability
230
+ print(f"\nSystem keyring available: {'Yes' if self.keyring_available() else 'No'}")
231
+
232
+ # Check keyring credentials
233
+ keyring_key, keyring_secret = self.get_credentials_from_keyring()
234
+ has_keyring = bool(keyring_key and keyring_secret)
235
+ print(f"Credentials in keyring: {'Yes' if has_keyring else 'No'}")
236
+
237
+ # Check environment variables
238
+ env_key, env_secret = self.get_credentials_from_env()
239
+ has_env = bool(env_key and env_secret)
240
+ print(f"Credentials in environment: {'Yes' if has_env else 'No'}")
241
+
242
+ # Show which source will be used
243
+ if has_keyring:
244
+ print("\n[Active] Using credentials from system keyring")
245
+ elif has_env:
246
+ print("\n[Active] Using credentials from environment variables")
247
+ else:
248
+ print("\n[Warning] No credentials configured")
249
+ print(" Run with --configure to set up credentials")
250
+
251
+ print()
252
+
253
+
254
+ def get_credential_manager() -> CredentialManager:
255
+ """Get a configured credential manager instance."""
256
+ return CredentialManager()