hpde-analytics-cli 2.0.0__tar.gz → 6.0.1__tar.gz

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 (33) hide show
  1. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/PKG-INFO +3 -1
  2. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/api/client.py +192 -91
  3. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/auth/oauth.py +7 -3
  4. hpde_analytics_cli-6.0.1/hpde_analytics_cli/integrations/__init__.py +1 -0
  5. hpde_analytics_cli-6.0.1/hpde_analytics_cli/integrations/email_populator.py +247 -0
  6. hpde_analytics_cli-6.0.1/hpde_analytics_cli/integrations/google_sheets.py +150 -0
  7. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/main.py +137 -5
  8. hpde_analytics_cli-6.0.1/hpde_analytics_cli/utils/data_export.py +394 -0
  9. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/utils/field_discovery.py +26 -23
  10. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/utils/report_generator.py +262 -222
  11. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/PKG-INFO +3 -1
  12. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/SOURCES.txt +5 -0
  13. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/requires.txt +2 -0
  14. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/pyproject.toml +3 -1
  15. hpde_analytics_cli-6.0.1/tests/test_email_populator.py +388 -0
  16. hpde_analytics_cli-6.0.1/tests/test_google_sheets.py +225 -0
  17. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_main.py +100 -0
  18. hpde_analytics_cli-2.0.0/hpde_analytics_cli/utils/data_export.py +0 -366
  19. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/LICENSE +0 -0
  20. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/README.md +0 -0
  21. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/__init__.py +0 -0
  22. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/api/__init__.py +0 -0
  23. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/auth/__init__.py +0 -0
  24. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/auth/credentials.py +0 -0
  25. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli/utils/__init__.py +0 -0
  26. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/dependency_links.txt +0 -0
  27. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/entry_points.txt +0 -0
  28. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/hpde_analytics_cli.egg-info/top_level.txt +0 -0
  29. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/setup.cfg +0 -0
  30. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_client.py +0 -0
  31. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_credentials.py +0 -0
  32. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_data_export.py +0 -0
  33. {hpde_analytics_cli-2.0.0 → hpde_analytics_cli-6.0.1}/tests/test_report_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hpde-analytics-cli
3
- Version: 2.0.0
3
+ Version: 6.0.1
4
4
  Summary: Data analysis and reporting tool for HPDE and Time Trials programs
5
5
  Author-email: Kevin Homan <homan13@gmail.com>
6
6
  License: MIT
@@ -30,6 +30,8 @@ Requires-Dist: requests-oauthlib>=1.3.0
30
30
  Requires-Dist: python-dotenv>=1.0.0
31
31
  Requires-Dist: openpyxl>=3.1.0
32
32
  Requires-Dist: keyring>=24.0.0
33
+ Requires-Dist: gspread>=6.0.0
34
+ Requires-Dist: google-auth>=2.22.0
33
35
  Provides-Extra: dev
34
36
  Requires-Dist: pytest>=7.0.0; extra == "dev"
35
37
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -11,6 +11,10 @@ from requests_oauthlib import OAuth1Session
11
11
 
12
12
  from hpde_analytics_cli.auth.oauth import MSROAuth
13
13
 
14
+ # Error message constants
15
+ ERR_EVENT_ID_REQUIRED = "Event ID is required"
16
+ ERR_ORG_ID_REQUIRED = "Organization ID is required"
17
+
14
18
 
15
19
  class APIError(Exception):
16
20
  """Exception raised for API errors."""
@@ -46,6 +50,121 @@ class MSRClient:
46
50
  """Get an authenticated OAuth session."""
47
51
  return self.oauth.get_oauth_session()
48
52
 
53
+ def _execute_http_request(
54
+ self,
55
+ method: str,
56
+ session: OAuth1Session,
57
+ url: str,
58
+ params: Optional[Dict[str, Any]],
59
+ headers: Dict[str, str],
60
+ ):
61
+ """Execute HTTP request based on method type."""
62
+ if method.upper() == "GET":
63
+ return session.get(url, params=params, headers=headers)
64
+ elif method.upper() == "POST":
65
+ return session.post(url, data=params, headers=headers)
66
+ else:
67
+ raise APIError(f"Unsupported HTTP method: {method}")
68
+
69
+ def _handle_response_status(self, response, endpoint: str) -> Optional[Dict[str, Any]]:
70
+ """
71
+ Handle HTTP response status codes.
72
+
73
+ Returns:
74
+ Parsed response data if successful, None if should retry
75
+
76
+ Raises:
77
+ APIError: For non-retryable errors
78
+ """
79
+ if response.status_code == 200:
80
+ data = response.json()
81
+ # MSR wraps responses in {"response": {...}}
82
+ if isinstance(data, dict) and "response" in data:
83
+ data = data["response"]
84
+ return data
85
+
86
+ if response.status_code == 401:
87
+ raise APIError(
88
+ "Authentication failed - tokens may be invalid or expired",
89
+ status_code=401,
90
+ response_body=response.text,
91
+ )
92
+
93
+ if response.status_code == 403:
94
+ raise APIError(
95
+ "Access forbidden - insufficient permissions",
96
+ status_code=403,
97
+ response_body=response.text,
98
+ )
99
+
100
+ if response.status_code == 404:
101
+ raise APIError(
102
+ f"Resource not found: {endpoint}",
103
+ status_code=404,
104
+ response_body=response.text,
105
+ )
106
+
107
+ if response.status_code >= 500:
108
+ # Server error - signal retry
109
+ return None
110
+
111
+ raise APIError(
112
+ f"Request failed with status {response.status_code}",
113
+ status_code=response.status_code,
114
+ response_body=response.text,
115
+ )
116
+
117
+ def _should_retry_on_server_error(
118
+ self, response, endpoint: str, attempt: int, retries: int
119
+ ) -> APIError:
120
+ """
121
+ Handle server error response and determine if retry should occur.
122
+
123
+ Args:
124
+ response: HTTP response object
125
+ endpoint: API endpoint path
126
+ attempt: Current attempt number
127
+ retries: Maximum number of retries
128
+
129
+ Returns:
130
+ APIError to raise if no more retries
131
+
132
+ Raises:
133
+ APIError: If no more retries remain
134
+ """
135
+ error = APIError(
136
+ f"Server error: {response.status_code}",
137
+ status_code=response.status_code,
138
+ response_body=response.text,
139
+ )
140
+ if attempt < retries:
141
+ time.sleep(self.retry_delay * (attempt + 1))
142
+ else:
143
+ raise error
144
+ return error
145
+
146
+ def _should_retry_on_exception(self, e: Exception, attempt: int, retries: int) -> APIError:
147
+ """
148
+ Handle request exception and determine if retry should occur.
149
+
150
+ Args:
151
+ e: Exception that occurred
152
+ attempt: Current attempt number
153
+ retries: Maximum number of retries
154
+
155
+ Returns:
156
+ APIError to raise if no more retries
157
+
158
+ Raises:
159
+ APIError: If no more retries remain
160
+ """
161
+ error = APIError(f"Request failed: {str(e)}")
162
+ if attempt < retries:
163
+ time.sleep(self.retry_delay * (attempt + 1))
164
+ else:
165
+ raise error
166
+ return error
167
+
49
168
  def _request(
50
169
  self,
51
170
  method: str,
@@ -89,64 +208,21 @@ class MSRClient:
89
208
 
90
209
  for attempt in range(retries + 1):
91
210
  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
- )
211
+ response = self._execute_http_request(method, session, url, params, headers)
212
+ result = self._handle_response_status(response, endpoint)
213
+
214
+ if result is not None:
215
+ return result
216
+
217
+ # Server error - retry
218
+ last_error = self._should_retry_on_server_error(
219
+ response, endpoint, attempt, retries
220
+ )
141
221
 
142
222
  except APIError:
143
223
  raise
144
224
  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
225
+ last_error = self._should_retry_on_exception(e, attempt, retries)
150
226
 
151
227
  raise last_error
152
228
 
@@ -173,7 +249,7 @@ class MSRClient:
173
249
  """
174
250
  org_id = organization_id or self.organization_id
175
251
  if not org_id:
176
- raise ValueError("Organization ID is required")
252
+ raise ValueError(ERR_ORG_ID_REQUIRED)
177
253
 
178
254
  return self._request("GET", f"/rest/calendars/organization/{org_id}")
179
255
 
@@ -190,7 +266,7 @@ class MSRClient:
190
266
  Endpoint: GET /rest/events/{event_id}/entrylist
191
267
  """
192
268
  if not event_id:
193
- raise ValueError("Event ID is required")
269
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
194
270
 
195
271
  return self._request("GET", f"/rest/events/{event_id}/entrylist")
196
272
 
@@ -207,7 +283,7 @@ class MSRClient:
207
283
  Endpoint: GET /rest/events/{event_id}/attendees
208
284
  """
209
285
  if not event_id:
210
- raise ValueError("Event ID is required")
286
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
211
287
 
212
288
  return self._request("GET", f"/rest/events/{event_id}/attendees")
213
289
 
@@ -224,7 +300,7 @@ class MSRClient:
224
300
  Endpoint: GET /rest/events/{event_id}/assignments
225
301
  """
226
302
  if not event_id:
227
- raise ValueError("Event ID is required")
303
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
228
304
 
229
305
  return self._request("GET", f"/rest/events/{event_id}/assignments")
230
306
 
@@ -241,10 +317,61 @@ class MSRClient:
241
317
  Endpoint: GET /rest/events/{event_id}/feeds/timing
242
318
  """
243
319
  if not event_id:
244
- raise ValueError("Event ID is required")
320
+ raise ValueError(ERR_EVENT_ID_REQUIRED)
245
321
 
246
322
  return self._request("GET", f"/rest/events/{event_id}/feeds/timing")
247
323
 
324
+ def _fetch_user_profile(self, results: Dict[str, Any]) -> None:
325
+ """Fetch and store user profile data."""
326
+ print(" Fetching /rest/me...")
327
+ try:
328
+ results["me"] = self.get_me()
329
+ print(" [OK] User profile retrieved")
330
+ except APIError as e:
331
+ print(f" [ERROR] {e}")
332
+ results["me"] = {"error": str(e)}
333
+
334
+ def _fetch_organization_calendar(
335
+ self, results: Dict[str, Any], event_id: Optional[str]
336
+ ) -> Optional[str]:
337
+ """
338
+ Fetch and store organization calendar data.
339
+
340
+ Returns:
341
+ Event ID if found in calendar, otherwise the input event_id
342
+ """
343
+ if not self.organization_id:
344
+ return event_id
345
+
346
+ print(f" Fetching organization calendar (org: {self.organization_id})...")
347
+ try:
348
+ results["calendar"] = self.get_organization_calendar()
349
+ print(" [OK] Calendar retrieved")
350
+
351
+ # Get first event ID if not provided
352
+ if not event_id:
353
+ events = results["calendar"].get("events", [])
354
+ if events:
355
+ event_id = events[0].get("id")
356
+ print(f" Using first event from calendar: {event_id}")
357
+ except APIError as e:
358
+ print(f" [ERROR] {e}")
359
+ results["calendar"] = {"error": str(e)}
360
+
361
+ return event_id
362
+
363
+ def _fetch_event_endpoint(
364
+ self, results: Dict[str, Any], key: str, method, description: str, event_id: str
365
+ ) -> None:
366
+ """Fetch data from a single event endpoint."""
367
+ print(f" Fetching {description} (event: {event_id})...")
368
+ try:
369
+ results[key] = method(event_id)
370
+ print(f" [OK] {description} retrieved")
371
+ except APIError as e:
372
+ print(f" [ERROR] {e}")
373
+ results[key] = {"error": str(e)}
374
+
248
375
  def get_all_endpoint_data(self, event_id: Optional[str] = None) -> Dict[str, Any]:
249
376
  """
250
377
  Fetch data from all available endpoints for field discovery.
@@ -255,33 +382,13 @@ class MSRClient:
255
382
  Returns:
256
383
  Dict with data from each endpoint, keyed by endpoint name
257
384
  """
258
- results = {}
385
+ results: Dict[str, Any] = {}
259
386
 
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)}
387
+ # Fetch user profile
388
+ self._fetch_user_profile(results)
268
389
 
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)}
390
+ # Fetch organization calendar and potentially get event_id
391
+ event_id = self._fetch_organization_calendar(results, event_id)
285
392
 
286
393
  # Event-specific endpoints (require event_id)
287
394
  if event_id:
@@ -293,13 +400,7 @@ class MSRClient:
293
400
  ]
294
401
 
295
402
  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)}
403
+ self._fetch_event_endpoint(results, key, method, description, event_id)
303
404
  else:
304
405
  print(" [SKIP] No event ID available - skipping event-specific endpoints")
305
406
  print(" Use --event-id to specify an event, or ensure calendar has events")
@@ -448,16 +448,20 @@ class MSROAuth:
448
448
  # Step 2: Get authorization URL
449
449
  auth_url = self.get_authorization_url()
450
450
 
451
+ # Always print the URL so the user can copy/paste it if needed
452
+ print(f"\nAuthorization URL:\n\n {auth_url}\n")
453
+
451
454
  # Open browser automatically if requested
452
455
  if auto_open_browser:
453
- print("\nAttempting to open browser automatically...")
456
+ print("Attempting to open browser automatically...")
454
457
  try:
455
458
  webbrowser.open(auth_url)
459
+ print(" Browser opened. If nothing appeared, copy the URL above.")
456
460
  except Exception as e:
457
461
  print(f" Could not open browser automatically: {e}")
458
- print("\nIf the browser does not open, check your default browser settings.")
462
+ print(" Copy the URL above and open it in your browser manually.")
459
463
  else:
460
- print("\nPlease complete authorization in your browser.")
464
+ print("Copy the URL above and open it in your browser.")
461
465
 
462
466
  # Step 3: Wait for callback with verifier
463
467
  verifier = self._wait_for_callback(server)
@@ -0,0 +1 @@
1
+ """Google Sheets integration module."""
@@ -0,0 +1,247 @@
1
+ """
2
+ Email Populator Module
3
+
4
+ Matches student names from Google Form responses to MSR event data
5
+ and populates email addresses in the Google Sheet.
6
+ """
7
+
8
+ import csv
9
+ import os
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+
13
+ class NameMatcher:
14
+ """Handles name normalization and matching between MSR and Sheet data."""
15
+
16
+ @staticmethod
17
+ def normalize(name: str) -> str:
18
+ """Normalize a name for comparison: lowercase, strip, collapse spaces."""
19
+ return " ".join(name.lower().split())
20
+
21
+ @staticmethod
22
+ def build_email_lookup(
23
+ entrylist: List[Dict],
24
+ attendees: List[Dict],
25
+ group_filter: str,
26
+ ) -> Dict[str, str]:
27
+ """
28
+ Build a lookup dict: normalized full name -> email.
29
+
30
+ Filters entrylist by group substring, then enriches with email from attendees.
31
+
32
+ Args:
33
+ entrylist: List of entrylist records (with firstName, lastName, group)
34
+ attendees: List of attendee records (with firstName, lastName, email)
35
+ group_filter: Case-insensitive substring to match against group field
36
+
37
+ Returns:
38
+ Dict mapping normalized "firstname lastname" -> email
39
+ """
40
+ # Build attendee email lookup by driver key (first|last)
41
+ attendee_emails: Dict[str, str] = {}
42
+ for att in attendees:
43
+ first = (att.get("firstName") or "").strip()
44
+ last = (att.get("lastName") or "").strip()
45
+ email = (att.get("email") or "").strip()
46
+ if first and last and email:
47
+ key = f"{first.lower()}|{last.lower()}"
48
+ attendee_emails[key] = email
49
+
50
+ # Filter entrylist by group and build name -> email lookup
51
+ email_lookup: Dict[str, str] = {}
52
+ group_filter_lower = group_filter.lower()
53
+
54
+ for entry in entrylist:
55
+ group = (entry.get("group") or "").lower()
56
+ if group_filter_lower not in group:
57
+ continue
58
+
59
+ first = (entry.get("firstName") or "").strip()
60
+ last = (entry.get("lastName") or "").strip()
61
+ if not first or not last:
62
+ continue
63
+
64
+ driver_key = f"{first.lower()}|{last.lower()}"
65
+ email = attendee_emails.get(driver_key, "")
66
+
67
+ if email:
68
+ full_name = NameMatcher.normalize(f"{first} {last}")
69
+ email_lookup[full_name] = email
70
+
71
+ return email_lookup
72
+
73
+ @staticmethod
74
+ def match_name(sheet_name: str, email_lookup: Dict[str, str]) -> Optional[str]:
75
+ """
76
+ Match a name from the Sheet against the MSR email lookup.
77
+
78
+ Args:
79
+ sheet_name: Name string from the Google Sheet
80
+ email_lookup: Dict of normalized name -> email
81
+
82
+ Returns:
83
+ Email address if matched, None otherwise
84
+ """
85
+ normalized = NameMatcher.normalize(sheet_name)
86
+ return email_lookup.get(normalized)
87
+
88
+
89
+ class EmailPopulator:
90
+ """Orchestrates the email population workflow."""
91
+
92
+ def __init__(self, verbose: bool = False):
93
+ self.verbose = verbose
94
+
95
+ def _log(self, message: str) -> None:
96
+ """Print message if verbose mode is on."""
97
+ if self.verbose:
98
+ print(message)
99
+
100
+ def _read_csv(self, filepath: str) -> List[Dict]:
101
+ """Read a CSV file into a list of dictionaries."""
102
+ rows = []
103
+ with open(filepath, "r", encoding="utf-8") as f:
104
+ reader = csv.DictReader(f)
105
+ for row in reader:
106
+ rows.append(row)
107
+ return rows
108
+
109
+ def load_msr_data_from_export(self, export_dir: str) -> Tuple[List[Dict], List[Dict]]:
110
+ """
111
+ Load entrylist and attendees from export CSV files.
112
+
113
+ Args:
114
+ export_dir: Directory containing entrylist.csv and attendees.csv
115
+
116
+ Returns:
117
+ Tuple of (entrylist records, attendee records)
118
+ """
119
+ entrylist_path = os.path.join(export_dir, "entrylist.csv")
120
+ attendees_path = os.path.join(export_dir, "attendees.csv")
121
+
122
+ if not os.path.exists(entrylist_path):
123
+ raise FileNotFoundError(f"entrylist.csv not found in {export_dir}")
124
+ if not os.path.exists(attendees_path):
125
+ raise FileNotFoundError(f"attendees.csv not found in {export_dir}")
126
+
127
+ entrylist = self._read_csv(entrylist_path)
128
+ attendees = self._read_csv(attendees_path)
129
+
130
+ self._log(
131
+ f" Loaded {len(entrylist)} entrylist records " f"and {len(attendees)} attendee records"
132
+ )
133
+
134
+ return entrylist, attendees
135
+
136
+ def load_msr_data_from_api(self, client: Any, event_id: str) -> Tuple[List[Dict], List[Dict]]:
137
+ """
138
+ Fetch entrylist and attendees from MSR API.
139
+
140
+ Args:
141
+ client: Authenticated MSRClient instance
142
+ event_id: MSR event ID
143
+
144
+ Returns:
145
+ Tuple of (entrylist records, attendee records)
146
+ """
147
+ self._log(" Fetching entrylist from MSR API...")
148
+ entrylist_data = client.get_event_entrylist(event_id)
149
+ entrylist = entrylist_data.get("assignments", [])
150
+
151
+ self._log(" Fetching attendees from MSR API...")
152
+ attendees_data = client.get_event_attendees(event_id)
153
+ attendees = attendees_data.get("attendees", [])
154
+
155
+ self._log(
156
+ f" Fetched {len(entrylist)} entrylist records "
157
+ f"and {len(attendees)} attendee records"
158
+ )
159
+
160
+ return entrylist, attendees
161
+
162
+ def populate_emails(
163
+ self,
164
+ sheets_client: Any,
165
+ sheet_id: str,
166
+ worksheet_name: Optional[str],
167
+ name_column: str,
168
+ email_column: str,
169
+ email_lookup: Dict[str, str],
170
+ ) -> Dict[str, Any]:
171
+ """
172
+ Read names from Sheet, match to emails, write results.
173
+
174
+ Skips rows where the email column is already populated.
175
+
176
+ Args:
177
+ sheets_client: Authenticated GoogleSheetsClient instance
178
+ sheet_id: Google Sheet ID
179
+ worksheet_name: Optional worksheet/tab name (default: first sheet)
180
+ name_column: Column letter or header text for student names
181
+ email_column: Column letter or header text for email addresses
182
+ email_lookup: Dict of normalized name -> email from MSR data
183
+
184
+ Returns:
185
+ Dict with keys: matched, unmatched, skipped, already_filled, total_rows
186
+ """
187
+ self._log("\n Connecting to Google Sheets...")
188
+ sheets_client.connect()
189
+
190
+ worksheet = sheets_client.open_sheet(sheet_id, worksheet_name)
191
+ self._log(f" Opened worksheet: {worksheet.title}")
192
+
193
+ name_col_idx = sheets_client.find_column_index(worksheet, name_column)
194
+ email_col_idx = sheets_client.find_column_index(worksheet, email_column)
195
+ self._log(f" Name column index: {name_col_idx}, Email column index: {email_col_idx}")
196
+
197
+ # Read all data at once to minimize API calls
198
+ all_records = worksheet.get_all_values()
199
+ if len(all_records) <= 1:
200
+ self._log(" Sheet has no data rows (only header or empty).")
201
+ return {
202
+ "matched": 0,
203
+ "unmatched": [],
204
+ "skipped": 0,
205
+ "already_filled": 0,
206
+ "total_rows": 0,
207
+ }
208
+
209
+ # Process rows (skip header row at index 0; Sheet rows start at 2)
210
+ updates = []
211
+ matched = 0
212
+ unmatched = []
213
+ skipped = 0
214
+ already_filled = 0
215
+
216
+ for row_idx, row in enumerate(all_records[1:], start=2):
217
+ name_value = row[name_col_idx - 1].strip() if len(row) >= name_col_idx else ""
218
+ email_value = row[email_col_idx - 1].strip() if len(row) >= email_col_idx else ""
219
+
220
+ if not name_value:
221
+ skipped += 1
222
+ continue
223
+
224
+ if email_value:
225
+ already_filled += 1
226
+ continue
227
+
228
+ email = NameMatcher.match_name(name_value, email_lookup)
229
+ if email:
230
+ updates.append((row_idx, email_col_idx, email))
231
+ matched += 1
232
+ self._log(f" [MATCH] Row {row_idx}: {name_value} -> {email}")
233
+ else:
234
+ unmatched.append({"row": row_idx, "name": name_value})
235
+ self._log(f" [NO MATCH] Row {row_idx}: {name_value}")
236
+
237
+ if updates:
238
+ self._log(f"\n Writing {len(updates)} email addresses to Sheet...")
239
+ sheets_client.batch_update_cells(worksheet, updates)
240
+
241
+ return {
242
+ "matched": matched,
243
+ "unmatched": unmatched,
244
+ "skipped": skipped,
245
+ "already_filled": already_filled,
246
+ "total_rows": len(all_records) - 1,
247
+ }